@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.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/bin/flare.js +2 -0
- package/dist/index.js +3664 -0
- package/package.json +56 -0
- package/templates/app/.dev.vars.example +13 -0
- package/templates/app/README.md +9 -0
- package/templates/app/_gitignore +10 -0
- package/templates/app/app/admin/actions.ts +113 -0
- package/templates/app/app/admin/layout.tsx +32 -0
- package/templates/app/app/admin/page.tsx +63 -0
- package/templates/app/app/api/auth/[...all]/route.ts +4 -0
- package/templates/app/app/api/health/route.ts +7 -0
- package/templates/app/app/api/storage/route.ts +6 -0
- package/templates/app/app/dashboard/page.tsx +18 -0
- package/templates/app/app/globals.css +156 -0
- package/templates/app/app/layout.tsx +26 -0
- package/templates/app/app/page.tsx +19 -0
- package/templates/app/app/sign-in/page.tsx +33 -0
- package/templates/app/app/sign-up/page.tsx +23 -0
- package/templates/app/components/admin/admin-header.tsx +78 -0
- package/templates/app/components/admin/fields/date-field.tsx +112 -0
- package/templates/app/components/admin/fields/field-widget.tsx +128 -0
- package/templates/app/components/admin/fields/file-field.tsx +302 -0
- package/templates/app/components/admin/fields/relation-field.tsx +132 -0
- package/templates/app/components/admin/query.ts +25 -0
- package/templates/app/components/admin/resource-form-page.tsx +55 -0
- package/templates/app/components/admin/resource-form.tsx +139 -0
- package/templates/app/components/admin/resource-icon.tsx +85 -0
- package/templates/app/components/admin/resource-nav.tsx +109 -0
- package/templates/app/components/admin/resource-table-toolbar.tsx +95 -0
- package/templates/app/components/admin/resource-table.tsx +251 -0
- package/templates/app/components/admin/row-actions.tsx +111 -0
- package/templates/app/components/auth-form.tsx +120 -0
- package/templates/app/components/sign-out-button.tsx +18 -0
- package/templates/app/components/ui/alert-dialog.tsx +196 -0
- package/templates/app/components/ui/alert.tsx +65 -0
- package/templates/app/components/ui/avatar.tsx +108 -0
- package/templates/app/components/ui/badge.tsx +53 -0
- package/templates/app/components/ui/breadcrumb.tsx +110 -0
- package/templates/app/components/ui/button.tsx +65 -0
- package/templates/app/components/ui/calendar.tsx +220 -0
- package/templates/app/components/ui/card.tsx +92 -0
- package/templates/app/components/ui/command.tsx +184 -0
- package/templates/app/components/ui/dialog.tsx +158 -0
- package/templates/app/components/ui/dropdown-menu.tsx +256 -0
- package/templates/app/components/ui/empty.tsx +103 -0
- package/templates/app/components/ui/field.tsx +248 -0
- package/templates/app/components/ui/input-group.tsx +170 -0
- package/templates/app/components/ui/input.tsx +20 -0
- package/templates/app/components/ui/label.tsx +23 -0
- package/templates/app/components/ui/pagination.tsx +127 -0
- package/templates/app/components/ui/popover.tsx +88 -0
- package/templates/app/components/ui/progress.tsx +30 -0
- package/templates/app/components/ui/select.tsx +189 -0
- package/templates/app/components/ui/separator.tsx +27 -0
- package/templates/app/components/ui/sheet.tsx +142 -0
- package/templates/app/components/ui/sidebar.tsx +726 -0
- package/templates/app/components/ui/skeleton.tsx +13 -0
- package/templates/app/components/ui/sonner.tsx +38 -0
- package/templates/app/components/ui/spinner.tsx +15 -0
- package/templates/app/components/ui/switch.tsx +34 -0
- package/templates/app/components/ui/table.tsx +115 -0
- package/templates/app/components/ui/textarea.tsx +17 -0
- package/templates/app/components/ui/tooltip.tsx +56 -0
- package/templates/app/components.json +21 -0
- package/templates/app/db/auth-schema.ts +118 -0
- package/templates/app/db/flare-schema.ts +15 -0
- package/templates/app/db/index.ts +10 -0
- package/templates/app/db/schema.ts +12 -0
- package/templates/app/drizzle.config.ts +8 -0
- package/templates/app/hooks/use-mobile.ts +19 -0
- package/templates/app/lib/admin-nav.ts +15 -0
- package/templates/app/lib/admin.ts +103 -0
- package/templates/app/lib/api.ts +24 -0
- package/templates/app/lib/auth-client.ts +4 -0
- package/templates/app/lib/auth.ts +44 -0
- package/templates/app/lib/cache.ts +83 -0
- package/templates/app/lib/mail.ts +36 -0
- package/templates/app/lib/realtime.ts +45 -0
- package/templates/app/lib/security.ts +13 -0
- package/templates/app/lib/session.ts +24 -0
- package/templates/app/lib/storage.ts +26 -0
- package/templates/app/lib/utils.ts +6 -0
- package/templates/app/migrations/0000_init.sql +66 -0
- package/templates/app/migrations/meta/0000_snapshot.json +438 -0
- package/templates/app/migrations/meta/_journal.json +13 -0
- package/templates/app/next.config.ts +5 -0
- package/templates/app/policies/index.ts +4 -0
- package/templates/app/postcss.config.mjs +7 -0
- package/templates/app/proxy.ts +20 -0
- package/templates/app/resources/index.ts +4 -0
- package/templates/app/resources/server.ts +6 -0
- package/templates/app/tsconfig.json +22 -0
- package/templates/app/vite.config.ts +27 -0
- package/templates/app/worker/index.ts +40 -0
- 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'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'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
|
+
}
|