@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
@@ -0,0 +1,251 @@
1
+ import Link from "next/link";
2
+ import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
3
+ import { formatValue, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
4
+ import { isSortable } from "@flaredev/core/server";
5
+ import { Alert, AlertDescription } from "@/components/ui/alert";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
9
+ import {
10
+ Pagination,
11
+ PaginationContent,
12
+ PaginationItem,
13
+ PaginationLink,
14
+ PaginationNext,
15
+ PaginationPrevious,
16
+ } from "@/components/ui/pagination";
17
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
18
+ import { adminPath, adminPermissions, adminResources, adminStore, requireAccess } from "@/lib/admin";
19
+ import { cn } from "@/lib/utils";
20
+ import { hrefWith, toSearchParams, type SearchParams } from "./query";
21
+ import { ResourceTableToolbar } from "./resource-table-toolbar";
22
+ import { RowActions } from "./row-actions";
23
+
24
+ type Column = { key: string; label: string; def: StoredField | { kind: "timestamp" } };
25
+
26
+ const NUMERIC = new Set(["int", "float"]);
27
+
28
+ /**
29
+ * Paginated, sortable, filterable list of a resource's records. Everything comes from the
30
+ * descriptor: columns (fields with `list`), sortable headers, search, and filters. State
31
+ * lives in the URL (?page, ?sort, ?q, ?filter[field]), so views are linkable and work
32
+ * without client-side data fetching.
33
+ */
34
+ export async function ResourceTable({ resource, searchParams }: { resource: Resource; searchParams: SearchParams }) {
35
+ await requireAccess(resource, "read");
36
+ const basePath = adminPath(resource);
37
+ const permissions = await adminPermissions(resource.name);
38
+ const params = toSearchParams(searchParams);
39
+ const store = adminStore(resource.name);
40
+
41
+ let result = await store.list(params);
42
+ let ignoredQuery = false;
43
+ if (!result.ok) {
44
+ ignoredQuery = true;
45
+ result = await store.list(new URLSearchParams());
46
+ }
47
+ if (!result.ok) throw new Error(result.error);
48
+ const { data: rows, meta } = result.data;
49
+
50
+ const fields = storedFields(resource);
51
+ const columns: Column[] = [
52
+ ...fields.filter(([, def]) => def.list !== false).map(([key, def]) => ({ key, label: def.label, def })),
53
+ { key: "createdAt", label: "Created", def: { kind: "timestamp" } },
54
+ ];
55
+
56
+ // Resolve belongsTo ids to their related record titles, one query per relation.
57
+ const byName = new Map(adminResources().map((r) => [r.name, r]));
58
+ const titles: Record<string, Record<string, string>> = {};
59
+ await Promise.all(
60
+ columns.map(async ({ key, def }) => {
61
+ if (def.kind !== "belongsTo") return;
62
+ const ids = rows.map((row) => row[key]).filter((id): id is string => typeof id === "string");
63
+ titles[key] = byName.has(def.target) ? await adminStore(def.target).titles(ids) : {};
64
+ }),
65
+ );
66
+
67
+ const sort = params.get("sort") ?? `${resource.defaultSort.direction === "desc" ? "-" : ""}${resource.defaultSort.field}`;
68
+ const sortField = sort.replace(/^-/, "");
69
+ const sortDescending = sort.startsWith("-");
70
+ const activeFilters = [...params.keys()].filter((key) => key.startsWith("filter[")).length + (params.get("q") ? 1 : 0);
71
+ const first = meta.total === 0 ? 0 : (meta.page - 1) * meta.perPage + 1;
72
+ const last = Math.min(meta.page * meta.perPage, meta.total);
73
+
74
+ return (
75
+ <div className="flex flex-col gap-4">
76
+ <div className="flex flex-wrap items-center justify-between gap-2">
77
+ <ResourceTableToolbar resource={resource} />
78
+ {permissions.create && (
79
+ <Button asChild>
80
+ <Link href={adminPath(resource, "new")}>
81
+ <PlusIcon data-icon="inline-start" />
82
+ New {resource.label.toLowerCase()}
83
+ </Link>
84
+ </Button>
85
+ )}
86
+ </div>
87
+
88
+ {ignoredQuery && (
89
+ <Alert>
90
+ <AlertDescription>Some filters or sorting in the address weren&apos;t valid for {resource.pluralLabel.toLowerCase()} and were ignored.</AlertDescription>
91
+ </Alert>
92
+ )}
93
+
94
+ {rows.length === 0 ? (
95
+ <Empty className="rounded-lg border border-dashed">
96
+ <EmptyHeader>
97
+ <EmptyTitle>{activeFilters ? `No ${resource.pluralLabel.toLowerCase()} match` : `No ${resource.pluralLabel.toLowerCase()} yet`}</EmptyTitle>
98
+ <EmptyDescription>
99
+ {activeFilters ? "Try a different search or clear the filters." : `Create the first ${resource.label.toLowerCase()} to see it here.`}
100
+ </EmptyDescription>
101
+ </EmptyHeader>
102
+ <EmptyContent>
103
+ {activeFilters || !permissions.create ? (
104
+ <Button variant="outline" asChild>
105
+ <Link href={basePath}>Clear filters</Link>
106
+ </Button>
107
+ ) : (
108
+ <Button asChild>
109
+ <Link href={adminPath(resource, "new")}>
110
+ <PlusIcon data-icon="inline-start" />
111
+ New {resource.label.toLowerCase()}
112
+ </Link>
113
+ </Button>
114
+ )}
115
+ </EmptyContent>
116
+ </Empty>
117
+ ) : (
118
+ <div className="overflow-hidden rounded-lg border bg-card">
119
+ <Table>
120
+ <TableHeader className="bg-muted">
121
+ <TableRow className="hover:bg-muted">
122
+ {columns.map((column) => {
123
+ const sortable = column.key === "createdAt" || (column.def.kind !== "timestamp" && isSortable(column.def));
124
+ const active = sortField === column.key;
125
+ const nextSort = active && !sortDescending ? `-${column.key}` : column.key;
126
+ const SortIcon = !active ? ChevronsUpDownIcon : sortDescending ? ArrowDownIcon : ArrowUpIcon;
127
+ return (
128
+ <TableHead key={column.key} className={cn(NUMERIC.has(column.def.kind) && "text-right")}>
129
+ {sortable ? (
130
+ <Link
131
+ href={hrefWith(basePath, params, { sort: nextSort })}
132
+ className="group inline-flex items-center gap-1 hover:text-foreground"
133
+ aria-label={`Sort by ${column.label}`}
134
+ >
135
+ {column.label}
136
+ <SortIcon className={cn("size-3.5", active ? "opacity-100" : "opacity-0 group-hover:opacity-60")} />
137
+ </Link>
138
+ ) : (
139
+ column.label
140
+ )}
141
+ </TableHead>
142
+ );
143
+ })}
144
+ <TableHead className="w-12">
145
+ <span className="sr-only">Actions</span>
146
+ </TableHead>
147
+ </TableRow>
148
+ </TableHeader>
149
+ <TableBody>
150
+ {rows.map((row) => {
151
+ const id = String(row.id);
152
+ return (
153
+ <TableRow key={id}>
154
+ {columns.map((column, index) => {
155
+ const value = row[column.key];
156
+ let content: React.ReactNode = formatValue(column.def, value);
157
+ if (column.def.kind === "enum" && value != null) {
158
+ const tone = statusTone(value);
159
+ content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
160
+ } else if (column.def.kind === "belongsTo" && typeof value === "string") {
161
+ const target = byName.get(column.def.target);
162
+ content = target ? (
163
+ <Link href={adminPath(target, value, "edit")} className="hover:underline">
164
+ {titles[column.key]?.[value] ?? value}
165
+ </Link>
166
+ ) : (
167
+ value
168
+ );
169
+ } else if (index === 0) {
170
+ content = (
171
+ <Link href={adminPath(resource, id, "edit")} className="font-medium hover:underline">
172
+ {content}
173
+ </Link>
174
+ );
175
+ }
176
+ return (
177
+ <TableCell
178
+ key={column.key}
179
+ className={cn(
180
+ "max-w-80 truncate",
181
+ NUMERIC.has(column.def.kind) && "text-right tabular-nums",
182
+ (column.def.kind === "timestamp" || column.def.kind === "datetime" || column.def.kind === "date") &&
183
+ "text-muted-foreground tabular-nums",
184
+ )}
185
+ >
186
+ {content}
187
+ </TableCell>
188
+ );
189
+ })}
190
+ <TableCell className="text-right">
191
+ <RowActions
192
+ resourceName={resource.name}
193
+ label={resource.label}
194
+ id={id}
195
+ editHref={adminPath(resource, id, "edit")}
196
+ canUpdate={permissions.update}
197
+ canDelete={permissions.delete}
198
+ />
199
+ </TableCell>
200
+ </TableRow>
201
+ );
202
+ })}
203
+ </TableBody>
204
+ </Table>
205
+ </div>
206
+ )}
207
+
208
+ {meta.total > 0 && (
209
+ <div className="flex flex-col items-center justify-between gap-3 text-sm text-muted-foreground sm:flex-row">
210
+ <p className="tabular-nums">
211
+ {first}–{last} of {meta.total} {meta.total === 1 ? resource.label.toLowerCase() : resource.pluralLabel.toLowerCase()}
212
+ {activeFilters > 0 && ` · ${activeFilters} filter${activeFilters === 1 ? "" : "s"} applied`}
213
+ </p>
214
+ {meta.totalPages > 1 && (
215
+ <Pagination className="mx-0 w-auto">
216
+ <PaginationContent>
217
+ <PaginationItem>
218
+ <PaginationPrevious
219
+ href={hrefWith(basePath, params, { page: String(Math.max(1, meta.page - 1)) })}
220
+ aria-disabled={meta.page === 1}
221
+ className={cn(meta.page === 1 && "pointer-events-none opacity-50")}
222
+ />
223
+ </PaginationItem>
224
+ {pageWindow(meta.page, meta.totalPages).map((page) => (
225
+ <PaginationItem key={page}>
226
+ <PaginationLink href={hrefWith(basePath, params, { page: String(page) })} isActive={page === meta.page}>
227
+ {page}
228
+ </PaginationLink>
229
+ </PaginationItem>
230
+ ))}
231
+ <PaginationItem>
232
+ <PaginationNext
233
+ href={hrefWith(basePath, params, { page: String(Math.min(meta.totalPages, meta.page + 1)) })}
234
+ aria-disabled={meta.page === meta.totalPages}
235
+ className={cn(meta.page === meta.totalPages && "pointer-events-none opacity-50")}
236
+ />
237
+ </PaginationItem>
238
+ </PaginationContent>
239
+ </Pagination>
240
+ )}
241
+ </div>
242
+ )}
243
+ </div>
244
+ );
245
+ }
246
+
247
+ /** Up to five page numbers centred on the current page. */
248
+ function pageWindow(page: number, totalPages: number): number[] {
249
+ const start = Math.max(1, Math.min(page - 2, totalPages - 4));
250
+ return Array.from({ length: Math.min(5, totalPages) }, (_, i) => start + i);
251
+ }
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState, useTransition } from "react";
6
+ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import { deleteRecordAction } from "@/app/admin/actions";
9
+ import {
10
+ AlertDialog,
11
+ AlertDialogCancel,
12
+ AlertDialogContent,
13
+ AlertDialogDescription,
14
+ AlertDialogFooter,
15
+ AlertDialogHeader,
16
+ AlertDialogTitle,
17
+ } from "@/components/ui/alert-dialog";
18
+ import { Button } from "@/components/ui/button";
19
+ import {
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuGroup,
23
+ DropdownMenuItem,
24
+ DropdownMenuSeparator,
25
+ DropdownMenuTrigger,
26
+ } from "@/components/ui/dropdown-menu";
27
+ import { Spinner } from "@/components/ui/spinner";
28
+
29
+ export function RowActions({
30
+ resourceName,
31
+ label,
32
+ id,
33
+ editHref,
34
+ canUpdate = true,
35
+ canDelete = true,
36
+ }: {
37
+ resourceName: string;
38
+ label: string;
39
+ id: string;
40
+ editHref: string;
41
+ canUpdate?: boolean;
42
+ canDelete?: boolean;
43
+ }) {
44
+ const router = useRouter();
45
+ const [confirming, setConfirming] = useState(false);
46
+ const [pending, startTransition] = useTransition();
47
+
48
+ function onDelete() {
49
+ startTransition(async () => {
50
+ const result = await deleteRecordAction(resourceName, id);
51
+ if (result.ok) {
52
+ toast.success(`${label} deleted.`);
53
+ setConfirming(false);
54
+ router.refresh();
55
+ } else {
56
+ toast.error(result.error);
57
+ }
58
+ });
59
+ }
60
+
61
+ if (!canUpdate && !canDelete) return null;
62
+
63
+ return (
64
+ <>
65
+ <DropdownMenu>
66
+ <DropdownMenuTrigger asChild>
67
+ <Button variant="ghost" size="icon" aria-label="Row actions">
68
+ <MoreHorizontalIcon />
69
+ </Button>
70
+ </DropdownMenuTrigger>
71
+ <DropdownMenuContent align="end">
72
+ {canUpdate && (
73
+ <DropdownMenuGroup>
74
+ <DropdownMenuItem asChild>
75
+ <Link href={editHref}>
76
+ <PencilIcon />
77
+ Edit
78
+ </Link>
79
+ </DropdownMenuItem>
80
+ </DropdownMenuGroup>
81
+ )}
82
+ {canUpdate && canDelete && <DropdownMenuSeparator />}
83
+ {canDelete && (
84
+ <DropdownMenuGroup>
85
+ <DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
86
+ <Trash2Icon />
87
+ Delete
88
+ </DropdownMenuItem>
89
+ </DropdownMenuGroup>
90
+ )}
91
+ </DropdownMenuContent>
92
+ </DropdownMenu>
93
+
94
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
95
+ <AlertDialogContent>
96
+ <AlertDialogHeader>
97
+ <AlertDialogTitle>Delete this {label.toLowerCase()}?</AlertDialogTitle>
98
+ <AlertDialogDescription>This can&apos;t be undone.</AlertDialogDescription>
99
+ </AlertDialogHeader>
100
+ <AlertDialogFooter>
101
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
102
+ <Button variant="destructive" onClick={onDelete} disabled={pending}>
103
+ {pending && <Spinner data-icon="inline-start" />}
104
+ Delete
105
+ </Button>
106
+ </AlertDialogFooter>
107
+ </AlertDialogContent>
108
+ </AlertDialog>
109
+ </>
110
+ );
111
+ }
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import { useState, type FormEvent } from "react";
4
+ import { authClient } from "@/lib/auth-client";
5
+
6
+ const PROVIDER_LABELS: Record<string, string> = { google: "Google", github: "GitHub" };
7
+
8
+ const inputClass =
9
+ "w-full rounded-md border border-[var(--border)] bg-transparent px-3 py-2 text-sm outline-none focus:border-[var(--foreground)]";
10
+
11
+ export function AuthForm({
12
+ mode,
13
+ next,
14
+ socialProviders = [],
15
+ initialError = null,
16
+ }: {
17
+ mode: "sign-in" | "sign-up";
18
+ next: string;
19
+ socialProviders?: string[];
20
+ initialError?: string | null;
21
+ }) {
22
+ const [error, setError] = useState<string | null>(initialError);
23
+ const [pending, setPending] = useState(false);
24
+
25
+ async function onSubmit(event: FormEvent<HTMLFormElement>) {
26
+ event.preventDefault();
27
+ setError(null);
28
+ setPending(true);
29
+ const form = new FormData(event.currentTarget);
30
+ const email = String(form.get("email"));
31
+ const password = String(form.get("password"));
32
+
33
+ const { error } =
34
+ mode === "sign-up"
35
+ ? await authClient.signUp.email({ email, password, name: String(form.get("name")) })
36
+ : await authClient.signIn.email({ email, password });
37
+
38
+ if (error) {
39
+ setError(error.message ?? "Something went wrong. Please try again.");
40
+ setPending(false);
41
+ return;
42
+ }
43
+ // Full navigation so server components re-render with the new session cookie.
44
+ window.location.assign(next);
45
+ }
46
+
47
+ async function signInWithProvider(provider: string) {
48
+ setError(null);
49
+ setPending(true);
50
+ // Redirects the browser to the provider; failures come back to /sign-in?error=...
51
+ const { error } = await authClient.signIn.social({
52
+ provider,
53
+ callbackURL: next,
54
+ errorCallbackURL: `/sign-in?next=${encodeURIComponent(next)}`,
55
+ });
56
+ if (error) {
57
+ setError(error.message ?? "Could not start sign-in. Please try again.");
58
+ setPending(false);
59
+ }
60
+ }
61
+
62
+ return (
63
+ <div className="flex flex-col gap-4">
64
+ {socialProviders.length > 0 && (
65
+ <>
66
+ <div className="flex flex-col gap-2">
67
+ {socialProviders.map((provider) => (
68
+ <button
69
+ key={provider}
70
+ type="button"
71
+ disabled={pending}
72
+ onClick={() => signInWithProvider(provider)}
73
+ className="rounded-md border border-[var(--border)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-muted)] disabled:opacity-60"
74
+ >
75
+ Continue with {PROVIDER_LABELS[provider] ?? provider}
76
+ </button>
77
+ ))}
78
+ </div>
79
+ <p className="text-center text-xs text-[var(--foreground-muted)]">or use email</p>
80
+ </>
81
+ )}
82
+
83
+ <form onSubmit={onSubmit} className="flex flex-col gap-4">
84
+ {mode === "sign-up" && (
85
+ <label className="flex flex-col gap-1.5 text-sm font-medium">
86
+ Name
87
+ <input name="name" required autoComplete="name" className={inputClass} />
88
+ </label>
89
+ )}
90
+ <label className="flex flex-col gap-1.5 text-sm font-medium">
91
+ Email
92
+ <input name="email" type="email" required autoComplete="email" className={inputClass} />
93
+ </label>
94
+ <label className="flex flex-col gap-1.5 text-sm font-medium">
95
+ Password
96
+ <input
97
+ name="password"
98
+ type="password"
99
+ required
100
+ minLength={8}
101
+ autoComplete={mode === "sign-up" ? "new-password" : "current-password"}
102
+ className={inputClass}
103
+ />
104
+ </label>
105
+ {error && (
106
+ <p role="alert" className="text-sm text-[var(--danger)]">
107
+ {error}
108
+ </p>
109
+ )}
110
+ <button
111
+ type="submit"
112
+ disabled={pending}
113
+ className="rounded-md bg-[var(--foreground)] px-4 py-2 text-sm font-medium text-[var(--background)] disabled:opacity-60"
114
+ >
115
+ {pending ? "Please wait…" : mode === "sign-up" ? "Create account" : "Sign in"}
116
+ </button>
117
+ </form>
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { authClient } from "@/lib/auth-client";
4
+
5
+ export function SignOutButton() {
6
+ return (
7
+ <button
8
+ type="button"
9
+ onClick={async () => {
10
+ await authClient.signOut();
11
+ window.location.assign("/sign-in");
12
+ }}
13
+ className="rounded-md border border-[var(--border)] px-3 py-1.5 text-sm font-medium hover:bg-[var(--surface-muted)]"
14
+ >
15
+ Sign out
16
+ </button>
17
+ );
18
+ }
@@ -0,0 +1,196 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+ import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
6
+
7
+ import { Button } from "@/components/ui/button"
8
+
9
+ function AlertDialog({
10
+ ...props
11
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
12
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
13
+ }
14
+
15
+ function AlertDialogTrigger({
16
+ ...props
17
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
18
+ return (
19
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
20
+ )
21
+ }
22
+
23
+ function AlertDialogPortal({
24
+ ...props
25
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
26
+ return (
27
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
28
+ )
29
+ }
30
+
31
+ function AlertDialogOverlay({
32
+ className,
33
+ ...props
34
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
35
+ return (
36
+ <AlertDialogPrimitive.Overlay
37
+ data-slot="alert-dialog-overlay"
38
+ className={cn(
39
+ "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ )
45
+ }
46
+
47
+ function AlertDialogContent({
48
+ className,
49
+ size = "default",
50
+ ...props
51
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
52
+ size?: "default" | "sm"
53
+ }) {
54
+ return (
55
+ <AlertDialogPortal>
56
+ <AlertDialogOverlay />
57
+ <AlertDialogPrimitive.Content
58
+ data-slot="alert-dialog-content"
59
+ data-size={size}
60
+ className={cn(
61
+ "group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
62
+ className
63
+ )}
64
+ {...props}
65
+ />
66
+ </AlertDialogPortal>
67
+ )
68
+ }
69
+
70
+ function AlertDialogHeader({
71
+ className,
72
+ ...props
73
+ }: React.ComponentProps<"div">) {
74
+ return (
75
+ <div
76
+ data-slot="alert-dialog-header"
77
+ className={cn(
78
+ "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
79
+ className
80
+ )}
81
+ {...props}
82
+ />
83
+ )
84
+ }
85
+
86
+ function AlertDialogFooter({
87
+ className,
88
+ ...props
89
+ }: React.ComponentProps<"div">) {
90
+ return (
91
+ <div
92
+ data-slot="alert-dialog-footer"
93
+ className={cn(
94
+ "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
95
+ className
96
+ )}
97
+ {...props}
98
+ />
99
+ )
100
+ }
101
+
102
+ function AlertDialogTitle({
103
+ className,
104
+ ...props
105
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
106
+ return (
107
+ <AlertDialogPrimitive.Title
108
+ data-slot="alert-dialog-title"
109
+ className={cn(
110
+ "text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
111
+ className
112
+ )}
113
+ {...props}
114
+ />
115
+ )
116
+ }
117
+
118
+ function AlertDialogDescription({
119
+ className,
120
+ ...props
121
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
122
+ return (
123
+ <AlertDialogPrimitive.Description
124
+ data-slot="alert-dialog-description"
125
+ className={cn("text-sm text-muted-foreground", className)}
126
+ {...props}
127
+ />
128
+ )
129
+ }
130
+
131
+ function AlertDialogMedia({
132
+ className,
133
+ ...props
134
+ }: React.ComponentProps<"div">) {
135
+ return (
136
+ <div
137
+ data-slot="alert-dialog-media"
138
+ className={cn(
139
+ "mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
140
+ className
141
+ )}
142
+ {...props}
143
+ />
144
+ )
145
+ }
146
+
147
+ function AlertDialogAction({
148
+ className,
149
+ variant = "default",
150
+ size = "default",
151
+ ...props
152
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
153
+ Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
154
+ return (
155
+ <Button variant={variant} size={size} asChild>
156
+ <AlertDialogPrimitive.Action
157
+ data-slot="alert-dialog-action"
158
+ className={cn(className)}
159
+ {...props}
160
+ />
161
+ </Button>
162
+ )
163
+ }
164
+
165
+ function AlertDialogCancel({
166
+ className,
167
+ variant = "outline",
168
+ size = "default",
169
+ ...props
170
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
171
+ Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
172
+ return (
173
+ <Button variant={variant} size={size} asChild>
174
+ <AlertDialogPrimitive.Cancel
175
+ data-slot="alert-dialog-cancel"
176
+ className={cn(className)}
177
+ {...props}
178
+ />
179
+ </Button>
180
+ )
181
+ }
182
+
183
+ export {
184
+ AlertDialog,
185
+ AlertDialogAction,
186
+ AlertDialogCancel,
187
+ AlertDialogContent,
188
+ AlertDialogDescription,
189
+ AlertDialogFooter,
190
+ AlertDialogHeader,
191
+ AlertDialogMedia,
192
+ AlertDialogOverlay,
193
+ AlertDialogPortal,
194
+ AlertDialogTitle,
195
+ AlertDialogTrigger,
196
+ }