@flaredev/cli 0.1.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/bin/flare.js +2 -0
  4. package/dist/index.js +3664 -0
  5. package/package.json +56 -0
  6. package/templates/app/.dev.vars.example +13 -0
  7. package/templates/app/README.md +9 -0
  8. package/templates/app/_gitignore +10 -0
  9. package/templates/app/app/admin/actions.ts +113 -0
  10. package/templates/app/app/admin/layout.tsx +32 -0
  11. package/templates/app/app/admin/page.tsx +63 -0
  12. package/templates/app/app/api/auth/[...all]/route.ts +4 -0
  13. package/templates/app/app/api/health/route.ts +7 -0
  14. package/templates/app/app/api/storage/route.ts +6 -0
  15. package/templates/app/app/dashboard/page.tsx +18 -0
  16. package/templates/app/app/globals.css +156 -0
  17. package/templates/app/app/layout.tsx +26 -0
  18. package/templates/app/app/page.tsx +19 -0
  19. package/templates/app/app/sign-in/page.tsx +33 -0
  20. package/templates/app/app/sign-up/page.tsx +23 -0
  21. package/templates/app/components/admin/admin-header.tsx +78 -0
  22. package/templates/app/components/admin/fields/date-field.tsx +112 -0
  23. package/templates/app/components/admin/fields/field-widget.tsx +128 -0
  24. package/templates/app/components/admin/fields/file-field.tsx +302 -0
  25. package/templates/app/components/admin/fields/relation-field.tsx +132 -0
  26. package/templates/app/components/admin/query.ts +25 -0
  27. package/templates/app/components/admin/resource-form-page.tsx +55 -0
  28. package/templates/app/components/admin/resource-form.tsx +139 -0
  29. package/templates/app/components/admin/resource-icon.tsx +85 -0
  30. package/templates/app/components/admin/resource-nav.tsx +109 -0
  31. package/templates/app/components/admin/resource-table-toolbar.tsx +95 -0
  32. package/templates/app/components/admin/resource-table.tsx +251 -0
  33. package/templates/app/components/admin/row-actions.tsx +111 -0
  34. package/templates/app/components/auth-form.tsx +120 -0
  35. package/templates/app/components/sign-out-button.tsx +18 -0
  36. package/templates/app/components/ui/alert-dialog.tsx +196 -0
  37. package/templates/app/components/ui/alert.tsx +65 -0
  38. package/templates/app/components/ui/avatar.tsx +108 -0
  39. package/templates/app/components/ui/badge.tsx +53 -0
  40. package/templates/app/components/ui/breadcrumb.tsx +110 -0
  41. package/templates/app/components/ui/button.tsx +65 -0
  42. package/templates/app/components/ui/calendar.tsx +220 -0
  43. package/templates/app/components/ui/card.tsx +92 -0
  44. package/templates/app/components/ui/command.tsx +184 -0
  45. package/templates/app/components/ui/dialog.tsx +158 -0
  46. package/templates/app/components/ui/dropdown-menu.tsx +256 -0
  47. package/templates/app/components/ui/empty.tsx +103 -0
  48. package/templates/app/components/ui/field.tsx +248 -0
  49. package/templates/app/components/ui/input-group.tsx +170 -0
  50. package/templates/app/components/ui/input.tsx +20 -0
  51. package/templates/app/components/ui/label.tsx +23 -0
  52. package/templates/app/components/ui/pagination.tsx +127 -0
  53. package/templates/app/components/ui/popover.tsx +88 -0
  54. package/templates/app/components/ui/progress.tsx +30 -0
  55. package/templates/app/components/ui/select.tsx +189 -0
  56. package/templates/app/components/ui/separator.tsx +27 -0
  57. package/templates/app/components/ui/sheet.tsx +142 -0
  58. package/templates/app/components/ui/sidebar.tsx +726 -0
  59. package/templates/app/components/ui/skeleton.tsx +13 -0
  60. package/templates/app/components/ui/sonner.tsx +38 -0
  61. package/templates/app/components/ui/spinner.tsx +15 -0
  62. package/templates/app/components/ui/switch.tsx +34 -0
  63. package/templates/app/components/ui/table.tsx +115 -0
  64. package/templates/app/components/ui/textarea.tsx +17 -0
  65. package/templates/app/components/ui/tooltip.tsx +56 -0
  66. package/templates/app/components.json +21 -0
  67. package/templates/app/db/auth-schema.ts +118 -0
  68. package/templates/app/db/flare-schema.ts +15 -0
  69. package/templates/app/db/index.ts +10 -0
  70. package/templates/app/db/schema.ts +12 -0
  71. package/templates/app/drizzle.config.ts +8 -0
  72. package/templates/app/hooks/use-mobile.ts +19 -0
  73. package/templates/app/lib/admin-nav.ts +15 -0
  74. package/templates/app/lib/admin.ts +103 -0
  75. package/templates/app/lib/api.ts +24 -0
  76. package/templates/app/lib/auth-client.ts +4 -0
  77. package/templates/app/lib/auth.ts +44 -0
  78. package/templates/app/lib/cache.ts +83 -0
  79. package/templates/app/lib/mail.ts +36 -0
  80. package/templates/app/lib/realtime.ts +45 -0
  81. package/templates/app/lib/security.ts +13 -0
  82. package/templates/app/lib/session.ts +24 -0
  83. package/templates/app/lib/storage.ts +26 -0
  84. package/templates/app/lib/utils.ts +6 -0
  85. package/templates/app/migrations/0000_init.sql +66 -0
  86. package/templates/app/migrations/meta/0000_snapshot.json +438 -0
  87. package/templates/app/migrations/meta/_journal.json +13 -0
  88. package/templates/app/next.config.ts +5 -0
  89. package/templates/app/policies/index.ts +4 -0
  90. package/templates/app/postcss.config.mjs +7 -0
  91. package/templates/app/proxy.ts +20 -0
  92. package/templates/app/resources/index.ts +4 -0
  93. package/templates/app/resources/server.ts +6 -0
  94. package/templates/app/tsconfig.json +22 -0
  95. package/templates/app/vite.config.ts +27 -0
  96. package/templates/app/worker/index.ts +40 -0
  97. package/templates/app/wrangler.jsonc +52 -0
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@flaredev/cli",
3
+ "version": "0.1.0",
4
+ "description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
5
+ "license": "MIT",
6
+ "author": "Muke Johnbaptist",
7
+ "homepage": "https://flare-docs.codetotech.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/MUKE-coder/flare-framework.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/MUKE-coder/flare-framework/issues"
15
+ },
16
+ "keywords": [
17
+ "cloudflare",
18
+ "workers",
19
+ "vinext",
20
+ "fullstack",
21
+ "framework",
22
+ "d1",
23
+ "drizzle",
24
+ "generator",
25
+ "admin",
26
+ "cli"
27
+ ],
28
+ "type": "module",
29
+ "bin": {
30
+ "flare": "./bin/flare.js"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "dist",
35
+ "templates",
36
+ "README.md"
37
+ ],
38
+ "dependencies": {
39
+ "cac": "^7.0.0",
40
+ "jiti": "^2.7.0",
41
+ "jsonc-parser": "^3.3.1",
42
+ "picocolors": "^1.1.1",
43
+ "@flaredev/core": "0.1.0"
44
+ },
45
+ "engines": {
46
+ "node": ">=22"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup",
53
+ "dev": "tsup --watch",
54
+ "typecheck": "tsc --noEmit"
55
+ }
56
+ }
@@ -0,0 +1,13 @@
1
+ # Local secrets for `vinext dev` / `wrangler dev`. Copy to .dev.vars (git-ignored).
2
+ # In production set them with `wrangler secret put <NAME>`.
3
+
4
+ # Signs sessions, tokens, and storage URLs. Generate with: openssl rand -base64 32
5
+ BETTER_AUTH_SECRET=
6
+
7
+ # Only needed on a custom domain, e.g. https://example.com
8
+ # BETTER_AUTH_URL=
9
+
10
+ # Resend (https://resend.com/api-keys). Leave empty locally to print emails to the console.
11
+ RESEND_API_KEY=
12
+ # Sender on a domain verified in Resend, e.g. "Acme <hello@acme.com>"
13
+ MAIL_FROM=
@@ -0,0 +1,9 @@
1
+ # __APP_NAME__
2
+
3
+ A [Flare](https://github.com/) app running on Cloudflare Workers via vinext.
4
+
5
+ ```bash
6
+ __PM__ run dev # start the dev server
7
+ __PM__ run build # production build
8
+ __PM__ run deploy # build and deploy to Cloudflare Workers
9
+ ```
@@ -0,0 +1,10 @@
1
+ node_modules
2
+ .env*
3
+ !.env.example
4
+ .dev.vars*
5
+ !.dev.vars.example
6
+ .next
7
+ next-env.d.ts
8
+ /dist/
9
+ .vinext/
10
+ .wrangler/
@@ -0,0 +1,113 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
5
+ import type { FieldIssue } from "@flaredev/core/server";
6
+ import { storage } from "@/lib/storage";
7
+ import { can, type PolicyAction } from "@flaredev/core";
8
+ import { adminPath, adminSession, adminStore, policyFor } from "@/lib/admin";
9
+
10
+ export type ActionResult<T = unknown> =
11
+ | { ok: true; data: T }
12
+ | { ok: false; status: number; error: string; issues?: FieldIssue[]; field?: string };
13
+
14
+ const forbidden = (message = "You don't have access to the admin."): ActionResult<never> => ({ ok: false, status: 403, error: message });
15
+
16
+ /** Session + policy check for one action on one resource. */
17
+ async function allowed(resourceName: string, action: PolicyAction): Promise<ActionResult<never> | undefined> {
18
+ const { allowed: inAdmin, role } = await adminSession();
19
+ if (!inAdmin) return forbidden();
20
+ if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
21
+ }
22
+
23
+ export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
24
+ const denied = await allowed(resourceName, "delete");
25
+ if (denied) return denied;
26
+
27
+ const store = adminStore(resourceName);
28
+ const result = await store.delete(id);
29
+ if (result.ok) revalidatePath(adminPath(store.resource));
30
+ return result;
31
+ }
32
+
33
+ export async function createRecordAction(resourceName: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
34
+ const denied = await allowed(resourceName, "create");
35
+ if (denied) return denied;
36
+
37
+ const store = adminStore(resourceName);
38
+ const result = await store.create(input);
39
+ if (result.ok) revalidatePath(adminPath(store.resource));
40
+ return result;
41
+ }
42
+
43
+ export async function updateRecordAction(resourceName: string, id: string, input: unknown): Promise<ActionResult<Record<string, unknown>>> {
44
+ const denied = await allowed(resourceName, "update");
45
+ if (denied) return denied;
46
+
47
+ const store = adminStore(resourceName);
48
+ const result = await store.update(id, input);
49
+ if (result.ok) revalidatePath(adminPath(store.resource));
50
+ return result;
51
+ }
52
+
53
+ /** The file field `fieldKey` of a resource, or a failure when it doesn't exist. */
54
+ function fileField(
55
+ resourceName: string,
56
+ fieldKey: string,
57
+ ): { failure: ActionResult<never> } | { field: FileField & { label: string }; prefix: string } {
58
+ const { resource } = adminStore(resourceName);
59
+ const field = resource.fields[fieldKey];
60
+ if (!field || field.kind !== "file") {
61
+ return { failure: { ok: false, status: 400, error: `${resourceName} has no file field "${fieldKey}".` } };
62
+ }
63
+ return { field, prefix: fileKeyPrefix(resource, fieldKey) };
64
+ }
65
+
66
+ /**
67
+ * A short-lived signed URL for uploading to a `file:[…]` field. The declared type and
68
+ * size are checked against the descriptor before signing, so a browser can't widen
69
+ * what the field accepts; the storage route then checks the bytes really are that type.
70
+ */
71
+ export async function createUploadUrlAction(
72
+ resourceName: string,
73
+ fieldKey: string,
74
+ file: { name: string; type: string; size: number },
75
+ ): Promise<ActionResult<{ url: string; key: string }>> {
76
+ // Uploading is part of creating or editing a record, so either permission is enough.
77
+ const deniedCreate = await allowed(resourceName, "create");
78
+ const deniedUpdate = await allowed(resourceName, "update");
79
+ if (deniedCreate && deniedUpdate) return deniedUpdate;
80
+
81
+ const target = fileField(resourceName, fieldKey);
82
+ if ("failure" in target) return target.failure;
83
+ const { field, prefix } = target;
84
+
85
+ const contentTypes = mimeTypesFor(field.accept);
86
+ if (!matchesContentType(file.type || "application/octet-stream", contentTypes)) {
87
+ return { ok: false, status: 415, error: `${field.label} accepts ${field.accept.join(", ")} files.`, field: fieldKey };
88
+ }
89
+ const maxBytes = fileMaxBytes(field);
90
+ if (file.size > maxBytes) {
91
+ return { ok: false, status: 413, error: `${field.label} must be ${Math.round(maxBytes / 1024 / 1024)} MB or smaller.`, field: fieldKey };
92
+ }
93
+
94
+ const upload = await storage.createUploadUrl({ key: createObjectKey(file.name, prefix), contentTypes, maxBytes });
95
+ return { ok: true, data: { url: upload.url, key: upload.key } };
96
+ }
97
+
98
+ /**
99
+ * A short-lived URL for viewing a file stored in a `file:[…]` field. Only keys under that
100
+ * field's prefix are signed, and only for roles that can work with the resource, so a
101
+ * guessed or borrowed key can't be read through a resource the role can't see.
102
+ */
103
+ export async function createReadUrlAction(resourceName: string, fieldKey: string, key: string): Promise<ActionResult<{ url: string }>> {
104
+ const denied = await allowed(resourceName, "read");
105
+ // Someone creating or editing a record can view what they just uploaded.
106
+ if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
107
+
108
+ const target = fileField(resourceName, fieldKey);
109
+ if ("failure" in target) return target.failure;
110
+ if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
111
+
112
+ return { ok: true, data: { url: await storage.createReadUrl({ key }) } };
113
+ }
@@ -0,0 +1,32 @@
1
+ import { cookies } from "next/headers";
2
+ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
3
+ import { Toaster } from "@/components/ui/sonner";
4
+ import { AdminHeader, type Theme } from "@/components/admin/admin-header";
5
+ import { ResourceNav } from "@/components/admin/resource-nav";
6
+ import { adminLinks } from "@/lib/admin-nav";
7
+ import { requireAdmin, visibleResources } from "@/lib/admin";
8
+
9
+ export default async function AdminLayout({ children }: { children: React.ReactNode }) {
10
+ const { session } = await requireAdmin();
11
+ const jar = await cookies();
12
+ const theme = (jar.get("flare-theme")?.value ?? "system") as Theme;
13
+ const collapsed = jar.get("sidebar_state")?.value === "false";
14
+
15
+ const resources = (await visibleResources()).map((resource) => ({
16
+ name: resource.name,
17
+ pluralLabel: resource.pluralLabel,
18
+ slug: resource.slug,
19
+ icon: resource.icon,
20
+ }));
21
+
22
+ return (
23
+ <SidebarProvider defaultOpen={!collapsed}>
24
+ <ResourceNav appName="__APP_NAME__" resources={resources} links={adminLinks} />
25
+ <SidebarInset>
26
+ <AdminHeader user={{ name: session.user.name, email: session.user.email }} initialTheme={theme} />
27
+ <main className="flex-1 p-6 md:p-8">{children}</main>
28
+ </SidebarInset>
29
+ <Toaster theme={theme} />
30
+ </SidebarProvider>
31
+ );
32
+ }
@@ -0,0 +1,63 @@
1
+ import Link from "next/link";
2
+ import { ArrowRightIcon, PlusIcon } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
6
+ import { resourceIcon } from "@/components/admin/resource-icon";
7
+ import { adminPath, recordCount, visibleResources } from "@/lib/admin";
8
+
9
+ /** Admin landing page: one card per resource with its record count. */
10
+ export default async function AdminPage() {
11
+ const resources = await visibleResources();
12
+ const counts = await Promise.all(resources.map((resource) => recordCount(resource.name)));
13
+
14
+ if (resources.length === 0) {
15
+ return (
16
+ <Empty className="rounded-lg border border-dashed">
17
+ <EmptyHeader>
18
+ <EmptyTitle>No resources yet</EmptyTitle>
19
+ <EmptyDescription>
20
+ Generate one with <code>flare gen resource Contact --fields &quot;name:string, email:string&quot;</code>.
21
+ </EmptyDescription>
22
+ </EmptyHeader>
23
+ </Empty>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <div className="flex flex-col gap-6">
29
+ <h1 className="text-2xl font-semibold">Overview</h1>
30
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
31
+ {resources.map((resource, index) => {
32
+ const Icon = resourceIcon(resource.icon);
33
+ return (
34
+ <Card key={resource.name}>
35
+ <CardHeader>
36
+ <CardDescription className="flex items-center gap-2">
37
+ <Icon className="size-5" />
38
+ {resource.pluralLabel}
39
+ </CardDescription>
40
+ <CardTitle className="text-3xl font-bold tabular-nums">{counts[index]!.toLocaleString()}</CardTitle>
41
+ <CardAction>
42
+ <Button variant="ghost" size="icon" aria-label={`New ${resource.label.toLowerCase()}`} asChild>
43
+ <Link href={adminPath(resource, "new")}>
44
+ <PlusIcon />
45
+ </Link>
46
+ </Button>
47
+ </CardAction>
48
+ </CardHeader>
49
+ <CardContent>
50
+ <Button variant="link" className="h-auto p-0" asChild>
51
+ <Link href={adminPath(resource)}>
52
+ View {resource.pluralLabel.toLowerCase()}
53
+ <ArrowRightIcon data-icon="inline-end" />
54
+ </Link>
55
+ </Button>
56
+ </CardContent>
57
+ </Card>
58
+ );
59
+ })}
60
+ </div>
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,4 @@
1
+ import { toNextJsHandler } from "better-auth/next-js";
2
+ import { auth } from "@/lib/auth";
3
+
4
+ export const { GET, POST } = toNextJsHandler(auth);
@@ -0,0 +1,7 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { getDb } from "@/db";
3
+
4
+ export async function GET() {
5
+ const [row] = await getDb().all<{ ok: number }>(sql`select 1 as ok`);
6
+ return Response.json({ ok: true, database: row?.ok === 1 });
7
+ }
@@ -0,0 +1,6 @@
1
+ import { storage } from "@/lib/storage";
2
+
3
+ // Redeems signed URLs from lib/storage.ts: PUT uploads, GET/HEAD reads.
4
+ export const GET = (request: Request) => storage.handleRequest(request);
5
+ export const HEAD = GET;
6
+ export const PUT = GET;
@@ -0,0 +1,18 @@
1
+ import { SignOutButton } from "@/components/sign-out-button";
2
+ import { requireSession } from "@/lib/session";
3
+
4
+ export default async function DashboardPage() {
5
+ const { user } = await requireSession("/dashboard");
6
+
7
+ return (
8
+ <main className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 p-8">
9
+ <div className="flex items-center justify-between">
10
+ <h1 className="text-2xl font-semibold">Dashboard</h1>
11
+ <SignOutButton />
12
+ </div>
13
+ <p className="text-sm text-[var(--foreground-muted)]">
14
+ Signed in as <span className="font-medium text-[var(--foreground)]">{user.email}</span>
15
+ </p>
16
+ </main>
17
+ );
18
+ }
@@ -0,0 +1,156 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+
4
+ /*
5
+ * Dark mode: the `dark` class on <html> (set from the theme cookie) or, with no
6
+ * explicit choice, the system preference. `light` forces light.
7
+ */
8
+ @custom-variant dark {
9
+ &:where(.dark, .dark *) {
10
+ @slot;
11
+ }
12
+ @media (prefers-color-scheme: dark) {
13
+ &:where(:not(.light, .light *)) {
14
+ @slot;
15
+ }
16
+ }
17
+ }
18
+
19
+ /*
20
+ * Design tokens (style-guide.md). Edit these to reskin the app; the shadcn/ui
21
+ * tokens below are derived from them. Note: the style guide's "accent" (primary
22
+ * buttons, active nav) is shadcn's --primary; shadcn's --accent is the hover surface.
23
+ */
24
+ :root {
25
+ --background: #ffffff;
26
+ --surface: #ffffff;
27
+ --surface-muted: #f7f7f8;
28
+ --border: #e5e5e7;
29
+ --foreground: #0a0a0a;
30
+ --foreground-muted: #6b7280;
31
+ --success: #16a34a;
32
+ --danger: #dc2626;
33
+ --warning: #d97706;
34
+ --radius: 0.5rem;
35
+ }
36
+
37
+ .dark {
38
+ --background: #0a0a0a;
39
+ --surface: #111111;
40
+ --surface-muted: #18181b;
41
+ --border: #27272a;
42
+ --foreground: #fafafa;
43
+ --foreground-muted: #9ca3af;
44
+ --success: #22c55e;
45
+ --danger: #ef4444;
46
+ --warning: #f59e0b;
47
+ }
48
+
49
+ @media (prefers-color-scheme: dark) {
50
+ :root:not(.light) {
51
+ --background: #0a0a0a;
52
+ --surface: #111111;
53
+ --surface-muted: #18181b;
54
+ --border: #27272a;
55
+ --foreground: #fafafa;
56
+ --foreground-muted: #9ca3af;
57
+ --success: #22c55e;
58
+ --danger: #ef4444;
59
+ --warning: #f59e0b;
60
+ }
61
+ }
62
+
63
+ :root {
64
+ color-scheme: light;
65
+ --card: var(--surface);
66
+ --card-foreground: var(--foreground);
67
+ --popover: var(--surface);
68
+ --popover-foreground: var(--foreground);
69
+ --primary: var(--foreground);
70
+ --primary-foreground: var(--background);
71
+ --secondary: var(--surface-muted);
72
+ --secondary-foreground: var(--foreground);
73
+ --muted: var(--surface-muted);
74
+ --muted-foreground: var(--foreground-muted);
75
+ --accent: var(--surface-muted);
76
+ --accent-foreground: var(--foreground);
77
+ --destructive: var(--danger);
78
+ --input: var(--border);
79
+ --ring: var(--foreground-muted);
80
+ --chart-1: var(--foreground);
81
+ --chart-2: #71717a;
82
+ --chart-3: #a1a1aa;
83
+ --chart-4: #d4d4d8;
84
+ --chart-5: var(--success);
85
+ --sidebar: var(--surface-muted);
86
+ --sidebar-foreground: var(--foreground);
87
+ --sidebar-primary: var(--foreground);
88
+ --sidebar-primary-foreground: var(--background);
89
+ --sidebar-accent: color-mix(in oklab, var(--foreground) 6%, var(--surface-muted));
90
+ --sidebar-accent-foreground: var(--foreground);
91
+ --sidebar-border: var(--border);
92
+ --sidebar-ring: var(--foreground-muted);
93
+ }
94
+
95
+ .dark {
96
+ color-scheme: dark;
97
+ }
98
+ @media (prefers-color-scheme: dark) {
99
+ :root:not(.light) {
100
+ color-scheme: dark;
101
+ }
102
+ }
103
+
104
+ @theme inline {
105
+ --font-sans: ui-sans-serif, -apple-system, "Inter", "Segoe UI", sans-serif;
106
+ --color-background: var(--background);
107
+ --color-foreground: var(--foreground);
108
+ --color-surface: var(--surface);
109
+ --color-surface-muted: var(--surface-muted);
110
+ --color-foreground-muted: var(--foreground-muted);
111
+ --color-success: var(--success);
112
+ --color-danger: var(--danger);
113
+ --color-warning: var(--warning);
114
+ --color-card: var(--card);
115
+ --color-card-foreground: var(--card-foreground);
116
+ --color-popover: var(--popover);
117
+ --color-popover-foreground: var(--popover-foreground);
118
+ --color-primary: var(--primary);
119
+ --color-primary-foreground: var(--primary-foreground);
120
+ --color-secondary: var(--secondary);
121
+ --color-secondary-foreground: var(--secondary-foreground);
122
+ --color-muted: var(--muted);
123
+ --color-muted-foreground: var(--muted-foreground);
124
+ --color-accent: var(--accent);
125
+ --color-accent-foreground: var(--accent-foreground);
126
+ --color-destructive: var(--destructive);
127
+ --color-border: var(--border);
128
+ --color-input: var(--input);
129
+ --color-ring: var(--ring);
130
+ --color-chart-1: var(--chart-1);
131
+ --color-chart-2: var(--chart-2);
132
+ --color-chart-3: var(--chart-3);
133
+ --color-chart-4: var(--chart-4);
134
+ --color-chart-5: var(--chart-5);
135
+ --color-sidebar: var(--sidebar);
136
+ --color-sidebar-foreground: var(--sidebar-foreground);
137
+ --color-sidebar-primary: var(--sidebar-primary);
138
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
139
+ --color-sidebar-accent: var(--sidebar-accent);
140
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
141
+ --color-sidebar-border: var(--sidebar-border);
142
+ --color-sidebar-ring: var(--sidebar-ring);
143
+ --radius-sm: calc(var(--radius) - 4px);
144
+ --radius-md: calc(var(--radius) - 2px);
145
+ --radius-lg: var(--radius);
146
+ --radius-xl: calc(var(--radius) + 4px);
147
+ }
148
+
149
+ @layer base {
150
+ * {
151
+ @apply border-border outline-ring/50;
152
+ }
153
+ body {
154
+ @apply bg-background text-foreground font-sans antialiased;
155
+ }
156
+ }
@@ -0,0 +1,26 @@
1
+ import type { Metadata } from "next";
2
+ import "./globals.css";
3
+
4
+ export const metadata: Metadata = {
5
+ title: "__APP_NAME__",
6
+ description: "Built with Flare",
7
+ };
8
+
9
+ /**
10
+ * Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
11
+ * first paint. It runs in the browser rather than reading cookies() here: a layout
12
+ * that reads cookies makes every page under it dynamic, and dynamic pages can never
13
+ * be served from the CDN cache.
14
+ */
15
+ const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
16
+
17
+ export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
18
+ return (
19
+ <html lang="en" suppressHydrationWarning>
20
+ <head>
21
+ <script dangerouslySetInnerHTML={{ __html: themeScript }} />
22
+ </head>
23
+ <body>{children}</body>
24
+ </html>
25
+ );
26
+ }
@@ -0,0 +1,19 @@
1
+ import Link from "next/link";
2
+
3
+ export default function Home() {
4
+ return (
5
+ <main className="mx-auto flex min-h-screen max-w-2xl flex-col justify-center gap-4 p-8">
6
+ <p className="text-sm font-medium text-[var(--foreground-muted)]">Flare</p>
7
+ <h1 className="text-2xl font-semibold">__APP_NAME__</h1>
8
+ <p className="text-sm text-[var(--foreground-muted)]">
9
+ Edit <code>app/page.tsx</code> to get started, or generate a resource with{" "}
10
+ <code>flare gen resource</code>.
11
+ </p>
12
+ <nav className="flex gap-3 text-sm font-medium">
13
+ <Link href="/sign-in" className="underline">Sign in</Link>
14
+ <Link href="/sign-up" className="underline">Sign up</Link>
15
+ <Link href="/dashboard" className="underline">Dashboard</Link>
16
+ </nav>
17
+ </main>
18
+ );
19
+ }
@@ -0,0 +1,33 @@
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { AuthForm } from "@/components/auth-form";
4
+ import { enabledSocialProviders } from "@/lib/auth";
5
+ import { getSession, safeRedirectPath } from "@/lib/session";
6
+
7
+ export default async function SignInPage({
8
+ searchParams,
9
+ }: {
10
+ searchParams: Promise<{ next?: string; error?: string }>;
11
+ }) {
12
+ const params = await searchParams;
13
+ const next = safeRedirectPath(params.next);
14
+ if (await getSession()) redirect(next);
15
+
16
+ return (
17
+ <main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 p-6">
18
+ <h1 className="text-2xl font-semibold">Sign in</h1>
19
+ <AuthForm
20
+ mode="sign-in"
21
+ next={next}
22
+ socialProviders={enabledSocialProviders()}
23
+ initialError={params.error ? "Sign-in with that provider failed. Please try again." : null}
24
+ />
25
+ <p className="text-sm text-[var(--foreground-muted)]">
26
+ No account?{" "}
27
+ <Link href={`/sign-up?next=${encodeURIComponent(next)}`} className="font-medium text-[var(--foreground)] underline">
28
+ Create one
29
+ </Link>
30
+ </p>
31
+ </main>
32
+ );
33
+ }
@@ -0,0 +1,23 @@
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { AuthForm } from "@/components/auth-form";
4
+ import { enabledSocialProviders } from "@/lib/auth";
5
+ import { getSession, safeRedirectPath } from "@/lib/session";
6
+
7
+ export default async function SignUpPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
8
+ const next = safeRedirectPath((await searchParams).next);
9
+ if (await getSession()) redirect(next);
10
+
11
+ return (
12
+ <main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 p-6">
13
+ <h1 className="text-2xl font-semibold">Create an account</h1>
14
+ <AuthForm mode="sign-up" next={next} socialProviders={enabledSocialProviders()} />
15
+ <p className="text-sm text-[var(--foreground-muted)]">
16
+ Already registered?{" "}
17
+ <Link href={`/sign-in?next=${encodeURIComponent(next)}`} className="font-medium text-[var(--foreground)] underline">
18
+ Sign in
19
+ </Link>
20
+ </p>
21
+ </main>
22
+ );
23
+ }