@flaredev/cli 0.4.1 → 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.
Files changed (48) hide show
  1. package/dist/index.js +685 -341
  2. package/package.json +2 -2
  3. package/templates/app/app/dashboard/costs/page.tsx +93 -0
  4. package/templates/app/app/dashboard/page.tsx +4 -7
  5. package/templates/app/app/page.tsx +10 -0
  6. package/templates/app/components/dashboard/cost-estimator.tsx +77 -0
  7. package/templates/app/components/dashboard/fields/field-widget.tsx +237 -186
  8. package/templates/app/components/dashboard/record-actions.tsx +7 -7
  9. package/templates/app/components/dashboard/record-detail.tsx +1 -1
  10. package/templates/app/components/dashboard/resource-form-sheet.tsx +86 -0
  11. package/templates/app/components/dashboard/resource-form.tsx +129 -33
  12. package/templates/app/components/dashboard/resource-icon.tsx +87 -85
  13. package/templates/app/components/dashboard/resource-stats.tsx +10 -9
  14. package/templates/app/components/dashboard/resource-table.tsx +4 -4
  15. package/templates/app/components/dashboard/row-actions.tsx +3 -3
  16. package/templates/app/lib/costs.ts +138 -0
  17. package/templates/app/lib/dashboard-nav.ts +5 -1
  18. package/templates/app/lib/dashboard.ts +66 -61
  19. package/templates/app/lib/number.ts +33 -0
  20. package/templates/app/lib/site.ts +56 -56
  21. package/templates/app/lib/suggest.ts +35 -0
  22. package/templates/app/lib/usage.ts +32 -0
  23. package/templates/next/.env.example +21 -0
  24. package/templates/next/README.md +16 -0
  25. package/templates/next/app/api/health/route.ts +7 -0
  26. package/templates/next/app/dashboard/account/password/page.tsx +19 -0
  27. package/templates/next/app/dashboard/account/security/page.tsx +31 -0
  28. package/templates/next/app/dashboard/actions.ts +277 -0
  29. package/templates/next/app/dashboard/layout.tsx +64 -0
  30. package/templates/next/app/dashboard/page.tsx +127 -0
  31. package/templates/next/components/dashboard/resource-chart.tsx +86 -0
  32. package/templates/next/lib/analytics.ts +71 -0
  33. package/templates/next/lib/audit.ts +80 -0
  34. package/templates/next/lib/auth-emails.ts +92 -0
  35. package/templates/next/lib/auth.ts +156 -0
  36. package/templates/next/lib/dashboard.ts +161 -0
  37. package/templates/next/lib/db.ts +24 -0
  38. package/templates/next/lib/mail.ts +35 -0
  39. package/templates/next/lib/realtime.ts +23 -0
  40. package/templates/next/lib/redis.ts +17 -0
  41. package/templates/next/lib/storage.ts +57 -0
  42. package/templates/next/lib/theme.ts +83 -0
  43. package/templates/next/lib/views.ts +23 -0
  44. package/templates/next/next.config.ts +13 -0
  45. package/templates/next/prisma/schema/base.prisma +162 -0
  46. package/templates/next/prisma.config.ts +15 -0
  47. package/templates/next/tsconfig.json +24 -0
  48. package/templates/app/components/dashboard/resource-form-dialog.tsx +0 -80
@@ -0,0 +1,138 @@
1
+ /**
2
+ * What Cloudflare charges, in one place.
3
+ *
4
+ * Checked against Cloudflare's pricing pages on 24 September 2026. They change; when
5
+ * they do, change them here rather than in the page that displays them. The docs cover
6
+ * the reasoning behind these numbers:
7
+ * https://flare-docs.codetotech.com/guides/costs/
8
+ *
9
+ * Nothing here is a bill. The app can see how much it stores; it cannot see how many
10
+ * requests it served or how many rows it read — those live in Cloudflare's analytics,
11
+ * behind an API token this app deliberately doesn't hold. So this is a model, and the
12
+ * page that shows it says so.
13
+ */
14
+
15
+ /** The Workers Paid subscription, which is the whole bill for most apps. */
16
+ export const SUBSCRIPTION_USD = 5;
17
+
18
+ /** Free plan allowances. Daily where Cloudflare counts them daily. */
19
+ export const FREE = {
20
+ requestsPerDay: 100_000,
21
+ /** CPU milliseconds per invocation — the one that bites a server-rendered app. */
22
+ cpuMsPerRequest: 10,
23
+ d1RowsReadPerDay: 5_000_000,
24
+ d1RowsWrittenPerDay: 100_000,
25
+ d1StorageGb: 5,
26
+ r2StorageGb: 10,
27
+ kvReadsPerDay: 100_000,
28
+ /** A thousand a day, and every cache miss is a write. */
29
+ kvWritesPerDay: 1_000,
30
+ } as const;
31
+
32
+ /** Paid plan: what's included each month, then the rate past it. */
33
+ export const PAID = {
34
+ requests: { included: 10_000_000, perMillionUsd: 0.3 },
35
+ cpuMs: { included: 30_000_000, perMillionUsd: 0.02 },
36
+ d1RowsRead: { included: 25_000_000_000, perMillionUsd: 0.001 },
37
+ d1RowsWritten: { included: 50_000_000, perMillionUsd: 1 },
38
+ d1StorageGb: { included: 5, perGbMonthUsd: 0.75 },
39
+ r2StorageGb: { included: 10, perGbMonthUsd: 0.015 },
40
+ kvReads: { included: 10_000_000, perMillionUsd: 0.5 },
41
+ kvWrites: { included: 1_000_000, perMillionUsd: 5 },
42
+ } as const;
43
+
44
+ /**
45
+ * What one page view costs, measured on the example shop.
46
+ *
47
+ * A dashboard page runs the stats query, the chart, the table's page and its capped
48
+ * count, plus one lookup per relation shown — four to six queries, reading around a
49
+ * hundred rows between them once the indexes are doing their job. A page on the public
50
+ * side is lighter. These are the assumptions the estimate below rests on, and they are
51
+ * written down so you can argue with them.
52
+ */
53
+ export const PER_VIEW = {
54
+ d1RowsRead: 100,
55
+ d1RowsWritten: 0.2,
56
+ kvReads: 4,
57
+ /** Cache misses are rarer than hits; most views read a warm entry. */
58
+ kvWrites: 0.3,
59
+ cpuMs: 25,
60
+ } as const;
61
+
62
+ export interface Estimate {
63
+ monthlyViews: number;
64
+ requests: number;
65
+ d1RowsRead: number;
66
+ d1RowsWritten: number;
67
+ kvReads: number;
68
+ kvWrites: number;
69
+ cpuMs: number;
70
+ /** Dollars on top of the subscription. */
71
+ overageUsd: number;
72
+ totalUsd: number;
73
+ /** Which free-plan allowance this traffic would break first, if any. */
74
+ freePlanVerdict: string;
75
+ }
76
+
77
+ const over = (used: number, included: number, perMillionUsd: number) =>
78
+ used <= included ? 0 : ((used - included) / 1_000_000) * perMillionUsd;
79
+
80
+ /** What a month at this traffic would cost, and whether the free plan could carry it. */
81
+ export function estimate(monthlyViews: number, storedGb: { d1: number; r2: number }): Estimate {
82
+ const requests = monthlyViews;
83
+ const d1RowsRead = monthlyViews * PER_VIEW.d1RowsRead;
84
+ const d1RowsWritten = monthlyViews * PER_VIEW.d1RowsWritten;
85
+ const kvReads = monthlyViews * PER_VIEW.kvReads;
86
+ const kvWrites = monthlyViews * PER_VIEW.kvWrites;
87
+ const cpuMs = monthlyViews * PER_VIEW.cpuMs;
88
+
89
+ const overageUsd =
90
+ over(requests, PAID.requests.included, PAID.requests.perMillionUsd) +
91
+ over(cpuMs, PAID.cpuMs.included, PAID.cpuMs.perMillionUsd) +
92
+ over(d1RowsRead, PAID.d1RowsRead.included, PAID.d1RowsRead.perMillionUsd) +
93
+ over(d1RowsWritten, PAID.d1RowsWritten.included, PAID.d1RowsWritten.perMillionUsd) +
94
+ over(kvReads, PAID.kvReads.included, PAID.kvReads.perMillionUsd) +
95
+ over(kvWrites, PAID.kvWrites.included, PAID.kvWrites.perMillionUsd) +
96
+ Math.max(0, storedGb.d1 - PAID.d1StorageGb.included) * PAID.d1StorageGb.perGbMonthUsd +
97
+ Math.max(0, storedGb.r2 - PAID.r2StorageGb.included) * PAID.r2StorageGb.perGbMonthUsd;
98
+
99
+ const daily = monthlyViews / 30;
100
+ const broken: string[] = [];
101
+ if (daily > FREE.requestsPerDay) broken.push("requests");
102
+ if (daily * PER_VIEW.d1RowsRead > FREE.d1RowsReadPerDay) broken.push("D1 rows read");
103
+ if (daily * PER_VIEW.kvWrites > FREE.kvWritesPerDay) broken.push("KV writes");
104
+ if (storedGb.r2 > FREE.r2StorageGb) broken.push("R2 storage");
105
+ if (storedGb.d1 > FREE.d1StorageGb) broken.push("D1 storage");
106
+
107
+ return {
108
+ monthlyViews,
109
+ requests,
110
+ d1RowsRead,
111
+ d1RowsWritten,
112
+ kvReads,
113
+ kvWrites,
114
+ cpuMs,
115
+ overageUsd,
116
+ totalUsd: SUBSCRIPTION_USD + overageUsd,
117
+ freePlanVerdict: broken.length === 0
118
+ ? "Within the free allowances — but see the CPU note below."
119
+ : `Past the free plan on ${broken.join(", ")}.`,
120
+ };
121
+ }
122
+
123
+ /** "1.2M", "34k", "912" — counts that have to fit on a card. */
124
+ export function compact(value: number): string {
125
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
126
+ if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 0 : 1)}k`;
127
+ return Math.round(value).toLocaleString();
128
+ }
129
+
130
+ /** "$5.00", "$12.34". */
131
+ export const usd = (value: number) => `$${value.toFixed(2)}`;
132
+
133
+ /** "2.4 GB", "812 MB". */
134
+ export function gb(value: number): string {
135
+ if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 2)} GB`;
136
+ const mb = value * 1024;
137
+ return mb >= 1 ? `${mb.toFixed(mb >= 10 ? 0 : 1)} MB` : `${Math.round(mb * 1024)} KB`;
138
+ }
@@ -12,4 +12,8 @@ export const generatedDashboardLinks: DashboardLink[] = [];
12
12
  // generated:end
13
13
 
14
14
  /** Links shown under "Platform" in the dashboard sidebar. */
15
- export const dashboardLinks: DashboardLink[] = [...generatedDashboardLinks, { label: "API reference", href: "/api/reference", icon: "book" }];
15
+ export const dashboardLinks: DashboardLink[] = [
16
+ { label: "Costs", href: "/dashboard/costs", icon: "wallet" },
17
+ ...generatedDashboardLinks,
18
+ { label: "API reference", href: "/api/reference", icon: "book" },
19
+ ];
@@ -1,7 +1,7 @@
1
1
  import { headers } from "next/headers";
2
2
  import { notFound, redirect } from "next/navigation";
3
3
  import { allowedActions, can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
4
- import { and, count, gte, lt } from "drizzle-orm";
4
+ import { count, sql } from "drizzle-orm";
5
5
  import type { SQLiteTable } from "drizzle-orm/sqlite-core";
6
6
  import { createResourceStore, type ResourceStore } from "@flaredev/core/server";
7
7
  import { getDb } from "@/db";
@@ -73,74 +73,79 @@ export function dashboardStore(name: string): ResourceStore {
73
73
  }
74
74
 
75
75
  /**
76
- * How many records a resource has. Cached in the data cache under the resource's tag,
77
- * so the dashboard does not count every table on every visit, and a write through the
78
- * admin or the API drops the count on its way out.
76
+ * Every number the stats above a resource's table need, in one query.
77
+ *
78
+ * The total, how many arrived in the last week and the week before, and how many hold
79
+ * each value of a status field — all of it from a single grouped scan, summed up here.
80
+ * It was four queries and three cache entries; on the free plan the cache entries were
81
+ * the expensive half, because Workers KV allows a thousand writes a day and every write
82
+ * to a record drops the entries tagged for it.
83
+ *
84
+ * Cached under the resource's tag, so a write through the admin or the API drops it on
85
+ * the way out and the next view recomputes it.
79
86
  */
80
- export function recordCount(name: string): Promise<number> {
81
- const counted = cached(
82
- async (resourceName: string) => {
83
- // Counted straight off the table, not through the store: a list stops counting at
84
- // 10,000 so that paging stays quick, and a stat wants the real number.
85
- const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
86
- if (!entry) return 0;
87
- const [row] = await getDb().select({ total: count() }).from(entry.table);
88
- return row?.total ?? 0;
89
- },
90
- ["flare", "record-count"],
91
- { tags: [resourceTag(name)], revalidate: TTL.short },
92
- );
93
- return counted(name);
87
+ export interface ResourceStats {
88
+ total: number;
89
+ /** Created in the last `days` days, and in the `days` before that. */
90
+ current: number;
91
+ previous: number;
92
+ /** Rows per value of the grouped field, when one was asked for. */
93
+ values: Record<string, number>;
94
94
  }
95
95
 
96
- /**
97
- * How many records hold each value of a field, in one grouped query rather than one
98
- * count per option. Used by the stats above a resource's table.
99
- */
100
- export function valueCounts(name: string, field: string): Promise<Record<string, number>> {
101
- const counted = cached(
102
- async (resourceName: string, key: string) => {
96
+ export function resourceStats(name: string, field?: string, days = 7): Promise<ResourceStats> {
97
+ const read = cached(
98
+ async (resourceName: string, key: string | undefined, windowDays: number): Promise<ResourceStats> => {
103
99
  const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
104
- const column = entry ? (entry.table as unknown as Record<string, unknown>)[key] : undefined;
105
- if (!entry || !column) return {};
106
- const rows = (await getDb()
107
- .select({ value: column as never, total: count() })
108
- .from(entry.table)
109
- .groupBy(column as never)) as { value: unknown; total: number }[];
110
- return Object.fromEntries(rows.map((row) => [String(row.value ?? ""), row.total]));
111
- },
112
- ["flare", "value-counts"],
113
- { tags: [resourceTag(name)], revalidate: TTL.short },
114
- );
115
- return counted(name, field);
116
- }
100
+ if (!entry) return { total: 0, current: 0, previous: 0, values: {} };
117
101
 
118
- /**
119
- * How many records a resource gained in the last `days` days, and in the `days` before
120
- * that, so a stat can show which way it's going. Cached like the totals.
121
- */
122
- export function recentCounts(name: string, days = 7): Promise<{ current: number; previous: number }> {
123
- const counts = cached(
124
- async (resourceName: string, windowDays: number) => {
125
- const entry = (resourceTables as unknown as Record<string, { table: SQLiteTable } | undefined>)[resourceName];
126
- const createdAt = entry ? (entry.table as unknown as Record<string, unknown>).createdAt : undefined;
127
- // A resource whose table has no createdAt (a custom one) simply has no trend.
128
- if (!entry || !createdAt) return { current: 0, previous: 0 };
102
+ const columns = entry.table as unknown as Record<string, unknown>;
103
+ const createdAt = columns.createdAt;
104
+ const grouped = key ? columns[key] : undefined;
105
+ // Milliseconds, not Dates: these go into a raw `sql` fragment, which doesn't pass
106
+ // through the column's mapper, and D1 refuses to bind an object.
129
107
  const window = windowDays * 86_400_000;
130
- const since = new Date(Date.now() - window);
131
- const before = new Date(Date.now() - window * 2);
132
- const column = createdAt as never;
133
- const [current] = await getDb().select({ total: count() }).from(entry.table).where(gte(column, since));
134
- const [previous] = await getDb()
135
- .select({ total: count() })
136
- .from(entry.table)
137
- .where(and(gte(column, before), lt(column, since)));
138
- return { current: current?.total ?? 0, previous: previous?.total ?? 0 };
108
+ const since = Date.now() - window;
109
+ const before = Date.now() - window * 2;
110
+
111
+ // A resource whose table has no createdAt (a custom one) simply has no trend.
112
+ const windows = createdAt
113
+ ? {
114
+ current: sql<number>`sum(case when ${createdAt as never} >= ${since} then 1 else 0 end)`,
115
+ previous: sql<number>`sum(case when ${createdAt as never} >= ${before} and ${createdAt as never} < ${since} then 1 else 0 end)`,
116
+ }
117
+ : { current: sql<number>`0`, previous: sql<number>`0` };
118
+
119
+ const select: Record<string, unknown> = { total: count(), ...windows };
120
+ if (grouped) select.value = grouped;
121
+ const query = getDb()
122
+ .select(select as never)
123
+ .from(entry.table);
124
+ const rows = (await (grouped ? query.groupBy(grouped as never) : query)) as {
125
+ total: number;
126
+ current: number;
127
+ previous: number;
128
+ value?: unknown;
129
+ }[];
130
+
131
+ const stats: ResourceStats = { total: 0, current: 0, previous: 0, values: {} };
132
+ for (const row of rows) {
133
+ stats.total += Number(row.total ?? 0);
134
+ stats.current += Number(row.current ?? 0);
135
+ stats.previous += Number(row.previous ?? 0);
136
+ if (grouped) stats.values[String(row.value ?? "")] = Number(row.total ?? 0);
137
+ }
138
+ return stats;
139
139
  },
140
- ["flare", "recent-count"],
141
- { tags: [resourceTag(name)], revalidate: TTL.short },
140
+ ["flare", "resource-stats"],
141
+ { tags: [resourceTag(name)], revalidate: TTL.medium },
142
142
  );
143
- return counts(name, days);
143
+ return read(name, field, days);
144
+ }
145
+
146
+ /** How many records a resource has. One field of {@link resourceStats}. */
147
+ export async function recordCount(name: string): Promise<number> {
148
+ return (await resourceStats(name)).total;
144
149
  }
145
150
 
146
151
  /** Percentage change between two periods, or undefined when there's nothing to compare. */
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Numbers as people read and type them.
3
+ *
4
+ * A price typed as 2000 is easier to check at a glance as 2,000, but the form and the
5
+ * API only ever see the plain number — the grouping is put on for display and taken off
6
+ * again on every keystroke, so nothing downstream has to unpick a comma.
7
+ *
8
+ * Kept free of server imports: the field widgets that use it run in the browser.
9
+ */
10
+
11
+ /** "2000.5" → "2,000.5". A half-typed "1," or "12." is left alone so typing isn't fought. */
12
+ export function groupDigits(value: string): string {
13
+ if (!value) return "";
14
+ const negative = value.startsWith("-");
15
+ const body = negative ? value.slice(1) : value;
16
+ const [whole = "", ...rest] = body.split(".");
17
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
18
+ // Keep a trailing "." while someone is still typing the decimals.
19
+ const decimals = rest.length > 0 ? `.${rest.join("")}` : "";
20
+ return `${negative ? "-" : ""}${grouped}${decimals}`;
21
+ }
22
+
23
+ /**
24
+ * "2,000.50" → "2000.50". Everything that isn't a digit goes, along with any minus sign
25
+ * that isn't leading and any second decimal point.
26
+ */
27
+ export function ungroup(value: string, allowDecimal: boolean): string {
28
+ const negative = value.trimStart().startsWith("-");
29
+ const digits = value.replace(/[^\d.]/g, "");
30
+ const [whole = "", ...rest] = digits.split(".");
31
+ const body = allowDecimal && rest.length > 0 ? `${whole}.${rest.join("")}` : whole;
32
+ return `${negative && body ? "-" : ""}${body}`;
33
+ }
@@ -1,56 +1,56 @@
1
- import type { ThemeName } from "./theme";
2
-
3
- /**
4
- * What the site says about itself: the home page, the sign-in screens' side panels and
5
- * the page titles read from here. Edit freely.
6
- */
7
- export const site = {
8
- name: "__APP_NAME__",
9
- tagline: "The fastest way to run your business",
10
- description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
11
- /**
12
- * The look of the whole app: sign-in screens, dashboard, admin and home page.
13
- * One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
14
- * overrides it when the app is built or run with `flare dev`.
15
- */
16
- theme: "__THEME__" as ThemeName,
17
- /** How the dashboard behaves. */
18
- dashboard: {
19
- /**
20
- * Where a record is created and edited: in a dialog over the list ("modal", the
21
- * default, which keeps the list, its filters and your place in it), or on its own
22
- * page ("page", better for long forms).
23
- */
24
- forms: "modal" as "modal" | "page",
25
- },
26
- /** The small pill above the home page headline; set to null to hide it. */
27
- announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
28
- /** The home page's feature grid (icon names from lucide.dev). */
29
- features: [
30
- { icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
31
- { icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
32
- { icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
33
- { icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
34
- { icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
35
- { icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
36
- ],
37
- /** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
38
- customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
39
- /** Shown beside the sign-up form. */
40
- highlights: [
41
- "Set up in minutes, with nothing to install",
42
- "Secure sign-in with passkeys and two-factor",
43
- "Your data stays yours: export it any time",
44
- ],
45
- /** Shown on the sign-in screen of themes with a quote panel. */
46
- testimonial: {
47
- quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
48
- author: "Alex Rivera",
49
- role: "Head of Operations",
50
- },
51
- links: {
52
- terms: "/terms",
53
- privacy: "/privacy",
54
- support: "mailto:support@example.com",
55
- },
56
- };
1
+ import type { ThemeName } from "./theme";
2
+
3
+ /**
4
+ * What the site says about itself: the home page, the sign-in screens' side panels and
5
+ * the page titles read from here. Edit freely.
6
+ */
7
+ export const site = {
8
+ name: "__APP_NAME__",
9
+ tagline: "The fastest way to run your business",
10
+ description: "__APP_NAME__ keeps your team, your customers and your data in one place, and it's live on the edge in every region.",
11
+ /**
12
+ * The look of the whole app: sign-in screens, dashboard, admin and home page.
13
+ * One of: default, coral, amber, sky, mono, emerald. FLARE_THEME (shell or .env)
14
+ * overrides it when the app is built or run with `flare dev`.
15
+ */
16
+ theme: "__THEME__" as ThemeName,
17
+ /** How the dashboard behaves. */
18
+ dashboard: {
19
+ /**
20
+ * Where a record is created and edited: in a dialog over the list ("modal", the
21
+ * default, which keeps the list, its filters and your place in it), or on its own
22
+ * page ("page", better for long forms).
23
+ */
24
+ forms: "sheet" as "sheet" | "page",
25
+ },
26
+ /** The small pill above the home page headline; set to null to hide it. */
27
+ announcement: { label: "New", text: "Passkeys and two-factor sign-in are here", href: "/sign-up" } as { label: string; text: string; href: string } | null,
28
+ /** The home page's feature grid (icon names from lucide.dev). */
29
+ features: [
30
+ { icon: "zap", title: "Fast everywhere", description: "Served from the edge in every region, so pages load in milliseconds wherever your customers are." },
31
+ { icon: "shield-check", title: "Secure by default", description: "Passkeys, two-factor sign-in, roles and rate limits, switched on from day one." },
32
+ { icon: "layout-dashboard", title: "One place for your data", description: "Every record your team keeps, with search, filters and an admin built in." },
33
+ { icon: "users", title: "Built for teams", description: "Invite your team, give each person the right access, and see who changed what." },
34
+ { icon: "plug", title: "Connects to your tools", description: "A documented API for every record, ready for your scripts and integrations." },
35
+ { icon: "credit-card", title: "Billing when you need it", description: "Plans, checkout and invoices through Stripe, without building a billing system." },
36
+ ],
37
+ /** Placeholder names for the logo strip: replace them with your customers (or remove the strip). */
38
+ customers: ["Acme", "Globex", "Initech", "Vandelay", "Northwind", "Brightline"],
39
+ /** Shown beside the sign-up form. */
40
+ highlights: [
41
+ "Set up in minutes, with nothing to install",
42
+ "Secure sign-in with passkeys and two-factor",
43
+ "Your data stays yours: export it any time",
44
+ ],
45
+ /** Shown on the sign-in screen of themes with a quote panel. */
46
+ testimonial: {
47
+ quote: "We moved our whole workflow over in an afternoon. The team hasn't looked back.",
48
+ author: "Alex Rivera",
49
+ role: "Head of Operations",
50
+ },
51
+ links: {
52
+ terms: "/terms",
53
+ privacy: "/privacy",
54
+ support: "mailto:support@example.com",
55
+ },
56
+ };
@@ -0,0 +1,35 @@
1
+ import { slugify, type StoredField } from "@flaredev/core";
2
+
3
+ /**
4
+ * Values a form can make up for itself.
5
+ *
6
+ * A unique code — a SKU, a slug, a reference — is the shop's business, not something
7
+ * worth typing by hand, but it isn't something to impose either: these are offered
8
+ * behind a button, and whatever comes out can be typed over.
9
+ *
10
+ * Only unique, unformatted strings get one. An email or a URL is unique too, and
11
+ * inventing one would be nonsense.
12
+ */
13
+ export function canSuggest(field: StoredField): boolean {
14
+ return field.kind === "string" && field.unique === true && (field.format === undefined || field.format === "slug");
15
+ }
16
+
17
+ /** Four digits, so two products added in the same minute don't collide. */
18
+ const tail = () => String(Math.floor(Math.random() * 10_000)).padStart(4, "0");
19
+
20
+ /**
21
+ * A code for `key`, derived from what's been typed so far.
22
+ *
23
+ * "Brushed steel desk lamp" gives the slug `brushed-steel-desk-lamp` and the SKU
24
+ * `BRU-4821` — recognisable at a glance in a list, which is the whole point of a code
25
+ * someone reads out over a counter.
26
+ */
27
+ export function suggestValue(key: string, field: StoredField, title: string): string {
28
+ const source = title.trim();
29
+ const format = field.kind === "string" ? field.format : undefined;
30
+ if (format === "slug" || /slug/i.test(key)) {
31
+ return source ? slugify(source) : `item-${tail()}`;
32
+ }
33
+ const letters = source.replace(/[^A-Za-z0-9]/g, "").slice(0, 3).toUpperCase();
34
+ return `${letters || "REF"}-${tail()}`;
35
+ }
@@ -0,0 +1,32 @@
1
+ import { cached, TTL } from "@/lib/cache";
2
+ import { storage } from "@/lib/storage";
3
+
4
+ /**
5
+ * How much this app has put in R2.
6
+ *
7
+ * Listing a bucket is a Class A operation — the expensive kind — so it is cached for an
8
+ * hour. A bucket with more objects than one listing returns is reported as far as the
9
+ * cap and no further, because walking a million keys to draw one number would cost more
10
+ * than the number is worth.
11
+ */
12
+ const MAX_KEYS = 1_000;
13
+
14
+ export const storedBytes = cached(
15
+ async (): Promise<{ files: number; bytes: number; capped: boolean }> => {
16
+ try {
17
+ if (!storage.bucket.list) return { files: 0, bytes: 0, capped: false };
18
+ const listed = await storage.bucket.list({ limit: MAX_KEYS });
19
+ const objects = listed.objects ?? [];
20
+ return {
21
+ files: objects.length,
22
+ bytes: objects.reduce((sum, object) => sum + (object.size ?? 0), 0),
23
+ capped: Boolean(listed.truncated),
24
+ };
25
+ } catch {
26
+ // No bucket bound, or no permission to list it: report nothing rather than fail.
27
+ return { files: 0, bytes: 0, capped: false };
28
+ }
29
+ },
30
+ ["flare", "stored-bytes"],
31
+ { revalidate: TTL.long },
32
+ );
@@ -0,0 +1,21 @@
1
+ # Postgres, from your Neon project (Dashboard → Connection string).
2
+ DATABASE_URL=
3
+
4
+ # Sessions and signed upload URLs. Generate with: openssl rand -base64 32
5
+ BETTER_AUTH_SECRET=
6
+ BETTER_AUTH_URL=http://localhost:3000
7
+
8
+ # File uploads, on an R2 bucket (Cloudflare → R2 → Manage API tokens).
9
+ # R2 serves what it stores for free, which is why it's here rather than Blob storage.
10
+ R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
11
+ R2_BUCKET=
12
+ R2_ACCESS_KEY_ID=
13
+ R2_SECRET_ACCESS_KEY=
14
+
15
+ # Optional. Rate limits and anything that has to be true across every instance.
16
+ UPSTASH_REDIS_REST_URL=
17
+ UPSTASH_REDIS_REST_TOKEN=
18
+
19
+ # Optional. Without these, email is printed to the console instead of sent.
20
+ RESEND_API_KEY=
21
+ MAIL_FROM=
@@ -0,0 +1,16 @@
1
+ # __APP_NAME__
2
+
3
+ A [Flare](https://flare-docs.codetotech.com) app on Next.js, Postgres and Prisma.
4
+
5
+ ```bash
6
+ cp .env.example .env # DATABASE_URL and BETTER_AUTH_SECRET, at least
7
+ __PM__ run db:migrate # prisma migrate dev: write and apply a migration
8
+ __PM__ run dev # start the dev server on http://localhost:3000
9
+ __PM__ run build # production build
10
+ __PM__ run deploy # deploy to Vercel
11
+ ```
12
+
13
+ After `flare gen resource`, run `__PM__ run db:migrate` to turn the new models
14
+ in `prisma/schema/resources.prisma` into a migration.
15
+
16
+ Docs: https://flare-docs.codetotech.com
@@ -0,0 +1,7 @@
1
+ import { prisma } from "@/lib/db";
2
+
3
+ /** Is the app up, and can it reach the database? */
4
+ export async function GET() {
5
+ const rows = (await prisma.$queryRaw`select 1 as ok`) as { ok: number }[];
6
+ return Response.json({ ok: true, database: rows[0]?.ok === 1 });
7
+ }
@@ -0,0 +1,19 @@
1
+ import { Password } from "@/components/account/sections";
2
+ import { prisma } from "@/lib/db";
3
+ import { requireSession } from "@/lib/session";
4
+
5
+ export const metadata = { title: "Password" };
6
+
7
+ /** Whether this person signs in with a password at all: social and passkey accounts have none. */
8
+ async function hasPassword(userId: string) {
9
+ const credential = await prisma.account.findFirst({
10
+ where: { userId, providerId: "credential" },
11
+ select: { id: true },
12
+ });
13
+ return Boolean(credential);
14
+ }
15
+
16
+ export default async function PasswordPage() {
17
+ const { user } = await requireSession("/dashboard/account/password");
18
+ return <Password hasPassword={await hasPassword(user.id)} email={user.email} />;
19
+ }
@@ -0,0 +1,31 @@
1
+ import { ConnectedAccounts, Passkeys, TwoFactor } from "@/components/account/sections";
2
+ import { prisma } from "@/lib/db";
3
+ import { enabledSocialProviders } from "@/lib/auth";
4
+ import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
5
+ import { requireSession } from "@/lib/session";
6
+
7
+ export const metadata = { title: "Security" };
8
+
9
+ /** Two-factor, passkeys and the accounts you can sign in with. */
10
+ export default async function SecurityPage() {
11
+ const { user } = await requireSession("/dashboard/account/security");
12
+ const credential = await prisma.account.findFirst({
13
+ where: { userId: user.id, providerId: "credential" },
14
+ select: { id: true },
15
+ });
16
+ const providers = enabledSocialProviders();
17
+
18
+ return (
19
+ <>
20
+ {twoFactorAvailable && (
21
+ <TwoFactor
22
+ enabled={Boolean((user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled)}
23
+ hasPassword={Boolean(credential)}
24
+ methods={authConfig.twoFactor}
25
+ />
26
+ )}
27
+ {authConfig.passkeys && <Passkeys />}
28
+ {providers.length > 0 && <ConnectedAccounts providers={providers} />}
29
+ </>
30
+ );
31
+ }