@flaredev/cli 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +851 -405
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +38 -5
- package/templates/app/app/dashboard/costs/page.tsx +93 -0
- package/templates/app/app/dashboard/page.tsx +4 -7
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- package/templates/app/app/page.tsx +10 -0
- package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
- package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
- package/templates/app/components/dashboard/fields/file-field.tsx +5 -1
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +2 -2
- package/templates/app/components/dashboard/record-actions.tsx +9 -9
- package/templates/app/components/dashboard/record-detail.tsx +6 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
- package/templates/app/components/dashboard/resource-form.tsx +261 -165
- package/templates/app/components/dashboard/resource-icon.tsx +87 -85
- package/templates/app/components/dashboard/resource-stats.tsx +10 -9
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +12 -9
- package/templates/app/components/dashboard/row-actions.tsx +5 -5
- package/templates/app/lib/costs.ts +138 -0
- package/templates/app/lib/dashboard-nav.ts +5 -1
- package/templates/app/lib/dashboard.ts +66 -61
- package/templates/app/lib/number.ts +33 -0
- package/templates/app/lib/site.ts +56 -56
- package/templates/app/lib/suggest.ts +35 -0
- package/templates/app/lib/usage.ts +32 -0
- package/templates/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3 -3
- package/templates/next/.env.example +21 -0
- package/templates/next/README.md +16 -0
- package/templates/next/app/api/health/route.ts +7 -0
- package/templates/next/app/dashboard/account/password/page.tsx +19 -0
- package/templates/next/app/dashboard/account/security/page.tsx +31 -0
- package/templates/next/app/dashboard/actions.ts +277 -0
- package/templates/next/app/dashboard/layout.tsx +64 -0
- package/templates/next/app/dashboard/page.tsx +127 -0
- package/templates/next/components/dashboard/resource-chart.tsx +86 -0
- package/templates/next/lib/analytics.ts +71 -0
- package/templates/next/lib/audit.ts +80 -0
- package/templates/next/lib/auth-emails.ts +92 -0
- package/templates/next/lib/auth.ts +156 -0
- package/templates/next/lib/dashboard.ts +161 -0
- package/templates/next/lib/db.ts +24 -0
- package/templates/next/lib/mail.ts +35 -0
- package/templates/next/lib/realtime.ts +23 -0
- package/templates/next/lib/redis.ts +17 -0
- package/templates/next/lib/storage.ts +57 -0
- package/templates/next/lib/theme.ts +83 -0
- package/templates/next/lib/views.ts +23 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/prisma/schema/base.prisma +162 -0
- package/templates/next/prisma.config.ts +15 -0
- package/templates/next/tsconfig.json +24 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"jiti": "^2.7.0",
|
|
42
42
|
"jsonc-parser": "^3.3.1",
|
|
43
43
|
"picocolors": "^1.1.1",
|
|
44
|
-
"@flaredev/core": "0.
|
|
44
|
+
"@flaredev/core": "0.5.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -10,7 +10,7 @@ import { getDb } from "@/db";
|
|
|
10
10
|
import { savedView } from "@/db/flare-schema";
|
|
11
11
|
import { toCsv } from "@/lib/csv";
|
|
12
12
|
import { storage } from "@/lib/storage";
|
|
13
|
-
import { can, storedFields, type PolicyAction } from "@flaredev/core";
|
|
13
|
+
import { can, storedFields, type PolicyAction, type Resource } from "@flaredev/core";
|
|
14
14
|
import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
|
|
15
15
|
|
|
16
16
|
export type ActionResult<T = unknown> =
|
|
@@ -26,6 +26,23 @@ async function allowed(resourceName: string, action: PolicyAction): Promise<Acti
|
|
|
26
26
|
if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Take a deleted record's uploads out of storage.
|
|
31
|
+
*
|
|
32
|
+
* The row is gone, so nothing can point at those objects again, and an app that never
|
|
33
|
+
* removes them pays to keep files no one can reach. A failure here is swallowed on
|
|
34
|
+
* purpose: the record is already deleted, and a stray object is a smaller problem than
|
|
35
|
+
* an action that reports failure after having done the work.
|
|
36
|
+
*/
|
|
37
|
+
async function removeUploads(resource: Resource, record: Record<string, unknown> | null | undefined): Promise<void> {
|
|
38
|
+
if (!record) return;
|
|
39
|
+
for (const [key, def] of storedFields(resource)) {
|
|
40
|
+
if (def.kind !== "file") continue;
|
|
41
|
+
const value = record[key];
|
|
42
|
+
if (typeof value === "string" && value) await storage.delete(value).catch(() => undefined);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
|
|
30
47
|
const denied = await allowed(resourceName, "delete");
|
|
31
48
|
if (denied) return denied;
|
|
@@ -34,6 +51,7 @@ export async function deleteRecordAction(resourceName: string, id: string): Prom
|
|
|
34
51
|
const existing = await store.get(id);
|
|
35
52
|
const result = await store.delete(id);
|
|
36
53
|
if (result.ok) {
|
|
54
|
+
await removeUploads(store.resource, existing.ok ? existing.data : null);
|
|
37
55
|
await recordAudit({
|
|
38
56
|
action: "delete",
|
|
39
57
|
resource: resourceName,
|
|
@@ -67,10 +85,15 @@ export async function deleteManyAction(resourceName: string, ids: string[]): Pro
|
|
|
67
85
|
// at it) shouldn't take the rest of the batch with it.
|
|
68
86
|
let deleted = 0;
|
|
69
87
|
let failed = 0;
|
|
88
|
+
const hasFiles = storedFields(store.resource).some(([, def]) => def.kind === "file");
|
|
70
89
|
for (const id of ids) {
|
|
90
|
+
// Only read the record first when there's an upload to clean up afterwards.
|
|
91
|
+
const existing = hasFiles ? await store.get(id) : null;
|
|
71
92
|
const result = await store.delete(id);
|
|
72
|
-
if (result.ok)
|
|
73
|
-
|
|
93
|
+
if (result.ok) {
|
|
94
|
+
deleted++;
|
|
95
|
+
if (existing?.ok) await removeUploads(store.resource, existing.data);
|
|
96
|
+
} else failed++;
|
|
74
97
|
}
|
|
75
98
|
if (deleted > 0) {
|
|
76
99
|
await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
|
|
@@ -233,7 +256,17 @@ export async function createUploadUrlAction(
|
|
|
233
256
|
* field's prefix are signed, and only for roles that can work with the resource, so a
|
|
234
257
|
* guessed or borrowed key can't be read through a resource the role can't see.
|
|
235
258
|
*/
|
|
236
|
-
export async function createReadUrlAction(
|
|
259
|
+
export async function createReadUrlAction(
|
|
260
|
+
resourceName: string,
|
|
261
|
+
fieldKey: string,
|
|
262
|
+
key: string,
|
|
263
|
+
/**
|
|
264
|
+
* Serve the file as a download with this name instead of showing it in the tab. Object
|
|
265
|
+
* keys carry an id to keep them unique, so without this a download arrives called
|
|
266
|
+
* `3f1c…-invoice.pdf` — pass the name the record holds.
|
|
267
|
+
*/
|
|
268
|
+
downloadAs?: string,
|
|
269
|
+
): Promise<ActionResult<{ url: string }>> {
|
|
237
270
|
const denied = await allowed(resourceName, "read");
|
|
238
271
|
// Someone creating or editing a record can view what they just uploaded.
|
|
239
272
|
if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
|
|
@@ -242,5 +275,5 @@ export async function createReadUrlAction(resourceName: string, fieldKey: string
|
|
|
242
275
|
if ("failure" in target) return target.failure;
|
|
243
276
|
if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
|
|
244
277
|
|
|
245
|
-
return { ok: true, data: { url: await storage.createReadUrl({ key }) } };
|
|
278
|
+
return { ok: true, data: { url: await storage.createReadUrl({ key, downloadAs: downloadAs || undefined }) } };
|
|
246
279
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { DatabaseIcon, FilesIcon, HardDriveIcon, LayersIcon } from "lucide-react";
|
|
2
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
4
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
5
|
+
import { CostEstimator } from "@/components/dashboard/cost-estimator";
|
|
6
|
+
import { PageHeader } from "@/components/dashboard/page-header";
|
|
7
|
+
import { StatCards } from "@/components/dashboard/stat-card";
|
|
8
|
+
import { compact, FREE, gb } from "@/lib/costs";
|
|
9
|
+
import { requireDashboard, resourceStats, visibleResources } from "@/lib/dashboard";
|
|
10
|
+
import { storedBytes } from "@/lib/usage";
|
|
11
|
+
|
|
12
|
+
export const metadata = { title: "Costs" };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What this app holds, and what it would cost to run.
|
|
16
|
+
*
|
|
17
|
+
* Two halves, and the difference between them matters. The top is measured: rows this
|
|
18
|
+
* app has stored, files it has put in R2. The bottom is modelled, because a Worker can
|
|
19
|
+
* see what it wrote but not how many requests it served — that lives in Cloudflare's
|
|
20
|
+
* analytics, behind an API token this app doesn't hold and shouldn't need.
|
|
21
|
+
*/
|
|
22
|
+
export default async function CostsPage() {
|
|
23
|
+
await requireDashboard("/dashboard/costs");
|
|
24
|
+
const resources = await visibleResources();
|
|
25
|
+
|
|
26
|
+
const [counts, stored] = await Promise.all([
|
|
27
|
+
Promise.all(resources.map(async (resource) => ({ resource, total: (await resourceStats(resource.name)).total }))),
|
|
28
|
+
storedBytes(),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const rows = counts.reduce((sum, entry) => sum + entry.total, 0);
|
|
32
|
+
const r2Gb = stored.bytes / 1024 ** 3;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<PageHeader
|
|
37
|
+
title="Costs"
|
|
38
|
+
description="What this app holds, and what it would cost to run."
|
|
39
|
+
crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: "Costs" }]}
|
|
40
|
+
/>
|
|
41
|
+
|
|
42
|
+
<StatCards
|
|
43
|
+
stats={[
|
|
44
|
+
{ label: "Rows stored", value: compact(rows), icon: DatabaseIcon, hint: `across ${resources.length} resources` },
|
|
45
|
+
{ label: "Files in R2", value: compact(stored.files), icon: FilesIcon },
|
|
46
|
+
{ label: "File storage", value: gb(r2Gb), icon: HardDriveIcon, hint: `${FREE.r2StorageGb} GB free` },
|
|
47
|
+
{ label: "Resources", value: resources.length, icon: LayersIcon },
|
|
48
|
+
]}
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
<Card>
|
|
52
|
+
<CardHeader>
|
|
53
|
+
<CardTitle>What's in the database</CardTitle>
|
|
54
|
+
</CardHeader>
|
|
55
|
+
<CardContent>
|
|
56
|
+
<Table>
|
|
57
|
+
<TableHeader>
|
|
58
|
+
<TableRow>
|
|
59
|
+
<TableHead>Resource</TableHead>
|
|
60
|
+
<TableHead className="text-right">Rows</TableHead>
|
|
61
|
+
</TableRow>
|
|
62
|
+
</TableHeader>
|
|
63
|
+
<TableBody>
|
|
64
|
+
{counts.map(({ resource, total }) => (
|
|
65
|
+
<TableRow key={resource.name}>
|
|
66
|
+
<TableCell>{resource.pluralLabel}</TableCell>
|
|
67
|
+
<TableCell className="text-right tabular-nums">{total.toLocaleString()}</TableCell>
|
|
68
|
+
</TableRow>
|
|
69
|
+
))}
|
|
70
|
+
</TableBody>
|
|
71
|
+
</Table>
|
|
72
|
+
</CardContent>
|
|
73
|
+
</Card>
|
|
74
|
+
|
|
75
|
+
<CostEstimator storedGb={{ d1: 0, r2: r2Gb }} />
|
|
76
|
+
|
|
77
|
+
<Alert>
|
|
78
|
+
<AlertTitle>This is an estimate, not a bill</AlertTitle>
|
|
79
|
+
<AlertDescription>
|
|
80
|
+
A Worker can see what it stored; it can't see how many requests it served or how many rows it read. Those are in{" "}
|
|
81
|
+
<a href="https://dash.cloudflare.com" target="_blank" rel="noreferrer" className="underline">
|
|
82
|
+
your Cloudflare dashboard
|
|
83
|
+
</a>
|
|
84
|
+
. The assumptions behind the numbers above are in <code>lib/costs.ts</code>, and the reasoning is in the{" "}
|
|
85
|
+
<a href="https://flare-docs.codetotech.com/guides/costs/" target="_blank" rel="noreferrer" className="underline">
|
|
86
|
+
cost guide
|
|
87
|
+
</a>
|
|
88
|
+
.
|
|
89
|
+
</AlertDescription>
|
|
90
|
+
</Alert>
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -10,7 +10,7 @@ import { StatCards, type Stat } from "@/components/dashboard/stat-card";
|
|
|
10
10
|
import { getDb } from "@/db";
|
|
11
11
|
import { passkey } from "@/db/auth-schema";
|
|
12
12
|
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
13
|
-
import { adminPermissions,
|
|
13
|
+
import { adminPermissions, resourcePath, resourceStats, trend, visibleResources } from "@/lib/dashboard";
|
|
14
14
|
import { requireSession } from "@/lib/session";
|
|
15
15
|
|
|
16
16
|
export const metadata = { title: "Dashboard" };
|
|
@@ -22,12 +22,9 @@ export default async function DashboardPage() {
|
|
|
22
22
|
|
|
23
23
|
const cards = await Promise.all(
|
|
24
24
|
resources.map(async (resource) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
adminPermissions(resource.name),
|
|
29
|
-
]);
|
|
30
|
-
return { resource, total, recent, permissions };
|
|
25
|
+
// One query per resource for the count and the trend, not two.
|
|
26
|
+
const [stats, permissions] = await Promise.all([resourceStats(resource.name), adminPermissions(resource.name)]);
|
|
27
|
+
return { resource, total: stats.total, recent: stats, permissions };
|
|
31
28
|
}),
|
|
32
29
|
);
|
|
33
30
|
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
@import "tw-animate-css";
|
|
3
|
-
/* One typeface per theme (self-hosted; only the active theme's font is downloaded). */
|
|
4
|
-
@import "@fontsource-variable/inter";
|
|
5
|
-
@import "@fontsource-variable/plus-jakarta-sans";
|
|
6
|
-
@import "@fontsource-variable/noto-sans";
|
|
7
|
-
@import "@fontsource-variable/geist";
|
|
8
|
-
@import "@fontsource-variable/figtree";
|
|
9
3
|
|
|
10
4
|
/*
|
|
11
5
|
* Dark mode: the `dark` class on <html> (set from the theme cookie) or, with no
|
|
@@ -1,28 +1,37 @@
|
|
|
1
|
-
import type { Metadata } from "next";
|
|
2
|
-
import { site } from "@/lib/site";
|
|
3
|
-
import { activeTheme } from "@/lib/theme";
|
|
4
|
-
import "./globals.css";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { site } from "@/lib/site";
|
|
3
|
+
import { activeTheme } from "@/lib/theme";
|
|
4
|
+
import "./globals.css";
|
|
5
|
+
// One typeface per theme, self-hosted; the browser only downloads the one in use.
|
|
6
|
+
// These are imported here rather than from globals.css because Tailwind inlines a CSS
|
|
7
|
+
// @import, and the font files' relative URLs are then resolved against the wrong file
|
|
8
|
+
// and never emitted — which shows up only in a build, as every font 404ing.
|
|
9
|
+
import "@fontsource-variable/inter";
|
|
10
|
+
import "@fontsource-variable/plus-jakarta-sans";
|
|
11
|
+
import "@fontsource-variable/noto-sans";
|
|
12
|
+
import "@fontsource-variable/geist";
|
|
13
|
+
import "@fontsource-variable/figtree";
|
|
14
|
+
|
|
15
|
+
export const metadata: Metadata = {
|
|
16
|
+
title: { default: site.name, template: `%s · ${site.name}` },
|
|
17
|
+
description: site.description,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
|
|
22
|
+
* first paint. It runs in the browser rather than reading cookies() here: a layout
|
|
23
|
+
* that reads cookies makes every page under it dynamic, and dynamic pages can never
|
|
24
|
+
* be served from the CDN cache.
|
|
25
|
+
*/
|
|
26
|
+
const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
|
|
27
|
+
|
|
28
|
+
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
29
|
+
return (
|
|
30
|
+
<html lang="en" data-theme={activeTheme} suppressHydrationWarning>
|
|
31
|
+
<head>
|
|
32
|
+
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
|
33
|
+
</head>
|
|
34
|
+
<body>{children}</body>
|
|
35
|
+
</html>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -114,6 +114,16 @@ export default function Home() {
|
|
|
114
114
|
<p>
|
|
115
115
|
© {new Date().getFullYear()} {site.name}
|
|
116
116
|
</p>
|
|
117
|
+
{/* Delete this line when the app is your own — it's here so whoever set the
|
|
118
|
+
project up can find what running it actually costs. */}
|
|
119
|
+
<a
|
|
120
|
+
href="https://flare-docs.codetotech.com/guides/costs/"
|
|
121
|
+
target="_blank"
|
|
122
|
+
rel="noreferrer"
|
|
123
|
+
className="hover:text-foreground"
|
|
124
|
+
>
|
|
125
|
+
Built with Flare · what it costs to run
|
|
126
|
+
</a>
|
|
117
127
|
<nav aria-label="Footer" className="flex gap-6">
|
|
118
128
|
<Link href={site.links.terms} className="hover:text-foreground">
|
|
119
129
|
Terms
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { Card, CardContent } from "@/components/ui/card";
|
|
5
|
+
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { compact, estimate, PER_VIEW, usd } from "@/lib/costs";
|
|
8
|
+
import { groupDigits, ungroup } from "@/lib/number";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a month would cost at a traffic level you choose.
|
|
12
|
+
*
|
|
13
|
+
* The arithmetic is in lib/costs.ts along with the per-view assumptions it rests on.
|
|
14
|
+
* This is a model, not a bill — the app can see what it stores, not what it served.
|
|
15
|
+
*/
|
|
16
|
+
export function CostEstimator({ storedGb }: { storedGb: { d1: number; r2: number } }) {
|
|
17
|
+
const [views, setViews] = useState("50000");
|
|
18
|
+
const monthly = Math.max(0, Number(views) || 0);
|
|
19
|
+
const result = useMemo(() => estimate(monthly, storedGb), [monthly, storedGb]);
|
|
20
|
+
|
|
21
|
+
const rows = [
|
|
22
|
+
{ label: "Requests", value: compact(result.requests), of: "10M included" },
|
|
23
|
+
{ label: "D1 rows read", value: compact(result.d1RowsRead), of: "25B included" },
|
|
24
|
+
{ label: "D1 rows written", value: compact(result.d1RowsWritten), of: "50M included" },
|
|
25
|
+
{ label: "KV reads", value: compact(result.kvReads), of: "10M included" },
|
|
26
|
+
{ label: "KV writes", value: compact(result.kvWrites), of: "1M included" },
|
|
27
|
+
{ label: "CPU", value: `${compact(result.cpuMs)} ms`, of: "30M ms included" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex flex-col gap-4">
|
|
32
|
+
<Field className="max-w-xs">
|
|
33
|
+
<FieldLabel htmlFor="views">Page views a month</FieldLabel>
|
|
34
|
+
<Input
|
|
35
|
+
id="views"
|
|
36
|
+
inputMode="numeric"
|
|
37
|
+
className="tabular-nums"
|
|
38
|
+
value={groupDigits(views)}
|
|
39
|
+
onChange={(event) => setViews(ungroup(event.target.value, false))}
|
|
40
|
+
/>
|
|
41
|
+
<FieldDescription>
|
|
42
|
+
At {PER_VIEW.d1RowsRead} rows read and {PER_VIEW.cpuMs} ms of CPU per view.
|
|
43
|
+
</FieldDescription>
|
|
44
|
+
</Field>
|
|
45
|
+
|
|
46
|
+
<Card>
|
|
47
|
+
<CardContent className="flex flex-col gap-4">
|
|
48
|
+
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
49
|
+
<span className="text-sm text-muted-foreground">Estimated monthly cost</span>
|
|
50
|
+
<span className="text-3xl font-semibold tabular-nums">{usd(result.totalUsd)}</span>
|
|
51
|
+
</div>
|
|
52
|
+
<p className="text-xs text-muted-foreground">
|
|
53
|
+
{result.overageUsd < 0.005
|
|
54
|
+
? "All of it the $5 Workers Paid subscription — this traffic is inside every included allowance."
|
|
55
|
+
: `$5 subscription plus ${usd(result.overageUsd)} of usage past what's included.`}
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
<div className="grid gap-x-6 gap-y-2 border-t pt-3 sm:grid-cols-2">
|
|
59
|
+
{rows.map((row) => (
|
|
60
|
+
<div key={row.label} className="flex items-baseline justify-between gap-3 text-sm">
|
|
61
|
+
<span className="text-muted-foreground">{row.label}</span>
|
|
62
|
+
<span className="tabular-nums">
|
|
63
|
+
{row.value} <span className="text-xs text-muted-foreground">/ {row.of}</span>
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<p className="border-t pt-3 text-xs text-muted-foreground">
|
|
70
|
+
<span className="font-medium text-foreground">Free plan: </span>
|
|
71
|
+
{result.freePlanVerdict}
|
|
72
|
+
</p>
|
|
73
|
+
</CardContent>
|
|
74
|
+
</Card>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|