@flaredev/cli 0.1.2 → 0.3.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 (89) hide show
  1. package/bin/flare.js +9 -1
  2. package/dist/index.js +985 -337
  3. package/package.json +3 -2
  4. package/templates/app/app/admin/page.tsx +9 -61
  5. package/templates/app/app/api/openapi.json/route.ts +23 -0
  6. package/templates/app/app/api/reference/route.ts +36 -0
  7. package/templates/app/app/dashboard/account/page.tsx +40 -0
  8. package/templates/app/app/{admin → dashboard}/actions.ts +109 -11
  9. package/templates/app/app/dashboard/import-actions.ts +91 -0
  10. package/templates/app/app/dashboard/layout.tsx +42 -0
  11. package/templates/app/app/dashboard/observability/page.tsx +174 -0
  12. package/templates/app/app/dashboard/page.tsx +123 -9
  13. package/templates/app/app/forgot-password/page.tsx +22 -0
  14. package/templates/app/app/globals.css +109 -4
  15. package/templates/app/app/layout.tsx +28 -26
  16. package/templates/app/app/page.tsx +126 -13
  17. package/templates/app/app/reset-password/page.tsx +31 -0
  18. package/templates/app/app/sign-in/page.tsx +49 -33
  19. package/templates/app/app/sign-up/page.tsx +42 -23
  20. package/templates/app/app/two-factor/page.tsx +25 -0
  21. package/templates/app/app/verify-email/page.tsx +33 -0
  22. package/templates/app/components/account/security-settings.tsx +522 -0
  23. package/templates/app/components/auth/auth-shell.tsx +284 -0
  24. package/templates/app/components/auth/otp-input.tsx +95 -0
  25. package/templates/app/components/auth/password-field.tsx +116 -0
  26. package/templates/app/components/auth/password-forms.tsx +76 -0
  27. package/templates/app/components/auth/provider-icons.tsx +39 -0
  28. package/templates/app/components/auth/sign-in-flow.tsx +306 -0
  29. package/templates/app/components/auth/sign-up-form.tsx +93 -0
  30. package/templates/app/components/auth/social-buttons.tsx +56 -0
  31. package/templates/app/components/auth/two-factor-form.tsx +139 -0
  32. package/templates/app/components/auth/ui.tsx +96 -0
  33. package/templates/app/components/auth/use-resend.ts +44 -0
  34. package/templates/app/components/auth/verify-email-form.tsx +135 -0
  35. package/templates/app/components/{admin/admin-header.tsx → dashboard/dashboard-header.tsx} +78 -78
  36. package/templates/app/components/{admin/resource-nav.tsx → dashboard/dashboard-sidebar.tsx} +66 -9
  37. package/templates/app/components/dashboard/export-button.tsx +41 -0
  38. package/templates/app/components/dashboard/fields/country-field.tsx +152 -0
  39. package/templates/app/components/{admin → dashboard}/fields/field-widget.tsx +186 -128
  40. package/templates/app/components/{admin → dashboard}/fields/file-field.tsx +302 -302
  41. package/templates/app/components/dashboard/fields/multi-select-field.tsx +46 -0
  42. package/templates/app/components/dashboard/fields/phone-field.tsx +86 -0
  43. package/templates/app/components/dashboard/import-dialog.tsx +487 -0
  44. package/templates/app/components/dashboard/page-header.tsx +57 -0
  45. package/templates/app/components/dashboard/resource-form-dialog.tsx +80 -0
  46. package/templates/app/components/{admin → dashboard}/resource-form-page.tsx +61 -55
  47. package/templates/app/components/{admin → dashboard}/resource-form.tsx +165 -139
  48. package/templates/app/components/dashboard/resource-stats.tsx +53 -0
  49. package/templates/app/components/{admin → dashboard}/resource-table.tsx +331 -251
  50. package/templates/app/components/{admin → dashboard}/row-actions.tsx +144 -111
  51. package/templates/app/components/dashboard/stat-card.tsx +69 -0
  52. package/templates/app/components/dashboard/table-selection.tsx +185 -0
  53. package/templates/app/components/dashboard/traffic-chart.tsx +32 -0
  54. package/templates/app/components/marketing/product-preview.tsx +70 -0
  55. package/templates/app/components/marketing/site-header.tsx +39 -0
  56. package/templates/app/components/sign-out-button.tsx +1 -1
  57. package/templates/app/components/ui/checkbox.tsx +25 -0
  58. package/templates/app/components/ui/radio-group.tsx +29 -0
  59. package/templates/app/db/auth-schema.ts +167 -118
  60. package/templates/app/db/flare-schema.ts +33 -1
  61. package/templates/app/lib/analytics.ts +121 -0
  62. package/templates/app/lib/api-docs.ts +16 -0
  63. package/templates/app/lib/audit.ts +80 -0
  64. package/templates/app/lib/auth-client.ts +16 -1
  65. package/templates/app/lib/auth-config.ts +38 -0
  66. package/templates/app/lib/auth-emails.ts +94 -0
  67. package/templates/app/lib/auth.ts +115 -6
  68. package/templates/app/lib/cache.ts +83 -83
  69. package/templates/app/lib/csv.ts +24 -0
  70. package/templates/app/lib/dashboard-nav.ts +15 -0
  71. package/templates/app/lib/{admin.ts → dashboard.ts} +138 -103
  72. package/templates/app/lib/password-rules.ts +159 -0
  73. package/templates/app/lib/site.ts +56 -0
  74. package/templates/app/lib/theme.ts +79 -0
  75. package/templates/app/migrations/0000_init.sql +47 -4
  76. package/templates/app/migrations/meta/0000_snapshot.json +311 -1
  77. package/templates/app/migrations/meta/_journal.json +1 -1
  78. package/templates/app/vite.config.ts +5 -3
  79. package/templates/app/worker-configuration.d.ts +15764 -0
  80. package/templates/locks/package-lock.json +9189 -0
  81. package/templates/locks/pnpm-lock.yaml +6347 -0
  82. package/templates/app/app/admin/layout.tsx +0 -32
  83. package/templates/app/components/auth-form.tsx +0 -120
  84. package/templates/app/lib/admin-nav.ts +0 -15
  85. /package/templates/app/components/{admin → dashboard}/fields/date-field.tsx +0 -0
  86. /package/templates/app/components/{admin → dashboard}/fields/relation-field.tsx +0 -0
  87. /package/templates/app/components/{admin → dashboard}/query.ts +0 -0
  88. /package/templates/app/components/{admin → dashboard}/resource-icon.tsx +0 -0
  89. /package/templates/app/components/{admin → dashboard}/resource-table-toolbar.tsx +0 -0
@@ -1,111 +1,144 @@
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
- }
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 type { Resource } from "@flaredev/core";
9
+ import { deleteRecordAction } from "@/app/dashboard/actions";
10
+ import {
11
+ AlertDialog,
12
+ AlertDialogCancel,
13
+ AlertDialogContent,
14
+ AlertDialogDescription,
15
+ AlertDialogFooter,
16
+ AlertDialogHeader,
17
+ AlertDialogTitle,
18
+ } from "@/components/ui/alert-dialog";
19
+ import { Button } from "@/components/ui/button";
20
+ import {
21
+ DropdownMenu,
22
+ DropdownMenuContent,
23
+ DropdownMenuGroup,
24
+ DropdownMenuItem,
25
+ DropdownMenuSeparator,
26
+ DropdownMenuTrigger,
27
+ } from "@/components/ui/dropdown-menu";
28
+ import { Spinner } from "@/components/ui/spinner";
29
+ import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
30
+
31
+ /**
32
+ * Edit and delete for one row. Edit opens a dialog when the row's record came with it
33
+ * (site.dashboard.forms is "modal"), and otherwise goes to the form page.
34
+ */
35
+ export function RowActions({
36
+ resource,
37
+ id,
38
+ record,
39
+ relations = {},
40
+ listHref,
41
+ editHref,
42
+ canUpdate = true,
43
+ canDelete = true,
44
+ }: {
45
+ resource: Resource;
46
+ id: string;
47
+ /** The record itself, when the form opens in a dialog. */
48
+ record?: Record<string, unknown>;
49
+ relations?: FormRelations;
50
+ listHref: string;
51
+ editHref: string;
52
+ canUpdate?: boolean;
53
+ canDelete?: boolean;
54
+ }) {
55
+ const router = useRouter();
56
+ const [confirming, setConfirming] = useState(false);
57
+ const [editing, setEditing] = useState(false);
58
+ const [pending, startTransition] = useTransition();
59
+ const { name: resourceName, label } = resource;
60
+
61
+ function onDelete() {
62
+ startTransition(async () => {
63
+ const result = await deleteRecordAction(resourceName, id);
64
+ if (result.ok) {
65
+ toast.success(`${label} deleted.`);
66
+ setConfirming(false);
67
+ router.refresh();
68
+ } else {
69
+ toast.error(result.error);
70
+ }
71
+ });
72
+ }
73
+
74
+ if (!canUpdate && !canDelete) return null;
75
+
76
+ return (
77
+ <>
78
+ <DropdownMenu>
79
+ <DropdownMenuTrigger asChild>
80
+ <Button variant="ghost" size="icon" aria-label="Row actions">
81
+ <MoreHorizontalIcon />
82
+ </Button>
83
+ </DropdownMenuTrigger>
84
+ <DropdownMenuContent align="end">
85
+ {canUpdate && (
86
+ <DropdownMenuGroup>
87
+ {record ? (
88
+ <DropdownMenuItem onSelect={() => setEditing(true)}>
89
+ <PencilIcon />
90
+ Edit
91
+ </DropdownMenuItem>
92
+ ) : (
93
+ <DropdownMenuItem asChild>
94
+ <Link href={editHref}>
95
+ <PencilIcon />
96
+ Edit
97
+ </Link>
98
+ </DropdownMenuItem>
99
+ )}
100
+ </DropdownMenuGroup>
101
+ )}
102
+ {canUpdate && canDelete && <DropdownMenuSeparator />}
103
+ {canDelete && (
104
+ <DropdownMenuGroup>
105
+ <DropdownMenuItem variant="destructive" onSelect={() => setConfirming(true)}>
106
+ <Trash2Icon />
107
+ Delete
108
+ </DropdownMenuItem>
109
+ </DropdownMenuGroup>
110
+ )}
111
+ </DropdownMenuContent>
112
+ </DropdownMenu>
113
+
114
+ {record && canUpdate && (
115
+ <ResourceFormDialog
116
+ resource={resource}
117
+ relations={relations}
118
+ listHref={listHref}
119
+ mode="edit"
120
+ id={id}
121
+ record={record}
122
+ open={editing}
123
+ onOpenChange={setEditing}
124
+ />
125
+ )}
126
+
127
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
128
+ <AlertDialogContent>
129
+ <AlertDialogHeader>
130
+ <AlertDialogTitle>Delete this {label.toLowerCase()}?</AlertDialogTitle>
131
+ <AlertDialogDescription>This can&apos;t be undone.</AlertDialogDescription>
132
+ </AlertDialogHeader>
133
+ <AlertDialogFooter>
134
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
135
+ <Button variant="destructive" onClick={onDelete} disabled={pending}>
136
+ {pending && <Spinner data-icon="inline-start" />}
137
+ Delete
138
+ </Button>
139
+ </AlertDialogFooter>
140
+ </AlertDialogContent>
141
+ </AlertDialog>
142
+ </>
143
+ );
144
+ }
@@ -0,0 +1,69 @@
1
+ import Link from "next/link";
2
+ import { ArrowRightIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ export interface Stat {
7
+ label: string;
8
+ value: number | string;
9
+ /** Under the number: "12 this week", "3 need attention". */
10
+ hint?: string;
11
+ /** Change against the previous period, as a percentage. Up isn't always good — see `goodDirection`. */
12
+ change?: number;
13
+ goodDirection?: "up" | "down";
14
+ icon?: React.ComponentType<{ className?: string }>;
15
+ href?: string;
16
+ }
17
+
18
+ const format = (value: number | string) => (typeof value === "number" ? value.toLocaleString() : value);
19
+
20
+ /** One number, what it means, and where to go for the detail behind it. */
21
+ export function StatCard({ label, value, hint, change, goodDirection = "up", icon: Icon, href }: Stat) {
22
+ const rising = (change ?? 0) >= 0;
23
+ const good = rising === (goodDirection === "up");
24
+ const Trend = rising ? TrendingUpIcon : TrendingDownIcon;
25
+
26
+ const card = (
27
+ <Card className={cn("h-full gap-3", href && "transition-colors hover:border-primary/40")}>
28
+ <CardHeader className="pb-0">
29
+ <CardDescription className="flex items-center gap-2">
30
+ {Icon && <Icon className="size-4" />}
31
+ {label}
32
+ </CardDescription>
33
+ <CardTitle className="text-3xl font-semibold tabular-nums">{format(value)}</CardTitle>
34
+ </CardHeader>
35
+ <CardContent className="flex items-center justify-between gap-2">
36
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
37
+ {change !== undefined && (
38
+ <span className={cn("inline-flex items-center gap-1 font-medium", good ? "text-success" : "text-danger")}>
39
+ <Trend className="size-3.5" />
40
+ {Math.abs(change).toFixed(change % 1 === 0 ? 0 : 1)}%
41
+ </span>
42
+ )}
43
+ {hint && <span>{hint}</span>}
44
+ </div>
45
+ {href && <ArrowRightIcon className="size-4 text-muted-foreground" aria-hidden="true" />}
46
+ </CardContent>
47
+ </Card>
48
+ );
49
+
50
+ return href ? (
51
+ <Link href={href} className="rounded-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
52
+ {card}
53
+ </Link>
54
+ ) : (
55
+ card
56
+ );
57
+ }
58
+
59
+ /** A row of stats that wraps: four across on a desktop, two on a tablet, one on a phone. */
60
+ export function StatCards({ stats }: { stats: Stat[] }) {
61
+ if (stats.length === 0) return null;
62
+ return (
63
+ <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
64
+ {stats.map((stat) => (
65
+ <StatCard key={stat.label} {...stat} />
66
+ ))}
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,185 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useMemo, useState, useTransition } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { DownloadIcon, Trash2Icon, XIcon } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { deleteManyAction } from "@/app/dashboard/actions";
8
+ import {
9
+ AlertDialog,
10
+ AlertDialogAction,
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 { Checkbox } from "@/components/ui/checkbox";
20
+ import { Spinner } from "@/components/ui/spinner";
21
+ import { toCsv } from "@/lib/csv";
22
+
23
+ interface SelectionValue {
24
+ ids: string[];
25
+ selected: Set<string>;
26
+ toggle: (id: string) => void;
27
+ toggleAll: () => void;
28
+ clear: () => void;
29
+ }
30
+
31
+ const SelectionContext = createContext<SelectionValue | null>(null);
32
+
33
+ function useSelection(): SelectionValue {
34
+ const value = useContext(SelectionContext);
35
+ if (!value) throw new Error("Selection components must be inside <TableSelection>.");
36
+ return value;
37
+ }
38
+
39
+ /**
40
+ * Selection for the rows on screen.
41
+ *
42
+ * Deliberately per page: a "select everything, all 40,000 of them" checkbox reads as
43
+ * harmless right up until the moment it isn't. To act on more than a page, filter down
44
+ * to what you mean first.
45
+ */
46
+ export function TableSelection({ ids, children }: { ids: string[]; children: React.ReactNode }) {
47
+ const [selected, setSelected] = useState<Set<string>>(new Set());
48
+
49
+ const toggle = useCallback((id: string) => {
50
+ setSelected((current) => {
51
+ const next = new Set(current);
52
+ if (!next.delete(id)) next.add(id);
53
+ return next;
54
+ });
55
+ }, []);
56
+
57
+ const toggleAll = useCallback(() => {
58
+ setSelected((current) => (current.size === ids.length ? new Set() : new Set(ids)));
59
+ }, [ids]);
60
+
61
+ const clear = useCallback(() => setSelected(new Set()), []);
62
+ const value = useMemo(() => ({ ids, selected, toggle, toggleAll, clear }), [ids, selected, toggle, toggleAll, clear]);
63
+
64
+ return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
65
+ }
66
+
67
+ export function SelectAllCheckbox({ label }: { label: string }) {
68
+ const { ids, selected, toggleAll } = useSelection();
69
+ const all = ids.length > 0 && selected.size === ids.length;
70
+ return (
71
+ <Checkbox
72
+ checked={all ? true : selected.size > 0 ? "indeterminate" : false}
73
+ onCheckedChange={toggleAll}
74
+ aria-label={all ? `Clear selection` : `Select all ${label} on this page`}
75
+ />
76
+ );
77
+ }
78
+
79
+ export function RowCheckbox({ id, label }: { id: string; label: string }) {
80
+ const { selected, toggle } = useSelection();
81
+ return <Checkbox checked={selected.has(id)} onCheckedChange={() => toggle(id)} aria-label={`Select ${label}`} />;
82
+ }
83
+
84
+ /** Highlights the row while it's selected, so the checkbox isn't the only signal. */
85
+ export function useRowSelected(id: string): boolean {
86
+ return useSelection().selected.has(id);
87
+ }
88
+
89
+ interface BarProps {
90
+ resourceName: string;
91
+ label: string;
92
+ pluralLabel: string;
93
+ canDelete: boolean;
94
+ /** Rows as they're shown, for exporting what's selected without another round trip. */
95
+ rows: Record<string, unknown>[];
96
+ columns: { key: string; label: string }[];
97
+ }
98
+
99
+ /**
100
+ * What you can do with the rows you've ticked. Appears only when something is selected,
101
+ * pinned to the bottom so it's reachable in a long table.
102
+ */
103
+ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows, columns }: BarProps) {
104
+ const { selected, clear } = useSelection();
105
+ const router = useRouter();
106
+ const [confirming, setConfirming] = useState(false);
107
+ const [pending, startTransition] = useTransition();
108
+ const count = selected.size;
109
+ if (count === 0) return null;
110
+
111
+ const noun = count === 1 ? label.toLowerCase() : pluralLabel.toLowerCase();
112
+
113
+ const exportSelected = () => {
114
+ const chosen = rows.filter((row) => selected.has(String(row.id)));
115
+ downloadCsv(`${resourceName.toLowerCase()}-selection.csv`, toCsv(chosen, columns));
116
+ };
117
+
118
+ const remove = () => {
119
+ startTransition(async () => {
120
+ const result = await deleteManyAction(resourceName, [...selected]);
121
+ setConfirming(false);
122
+ if (!result.ok) {
123
+ toast.error(result.error);
124
+ return;
125
+ }
126
+ const { deleted, failed } = result.data;
127
+ clear();
128
+ router.refresh();
129
+ if (failed > 0) toast.warning(`Deleted ${deleted}; ${failed} couldn't be deleted.`);
130
+ else toast.success(`Deleted ${deleted} ${deleted === 1 ? label.toLowerCase() : pluralLabel.toLowerCase()}.`);
131
+ });
132
+ };
133
+
134
+ return (
135
+ <>
136
+ <div className="sticky bottom-4 z-10 mx-auto flex w-fit items-center gap-2 rounded-full border bg-popover px-2 py-1.5 shadow-lg">
137
+ <span className="px-2 text-sm tabular-nums">
138
+ {count} {noun} selected
139
+ </span>
140
+ <Button variant="ghost" size="sm" onClick={exportSelected}>
141
+ <DownloadIcon data-icon="inline-start" />
142
+ Export
143
+ </Button>
144
+ {canDelete && (
145
+ <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)}>
146
+ <Trash2Icon data-icon="inline-start" />
147
+ Delete
148
+ </Button>
149
+ )}
150
+ <Button variant="ghost" size="icon" onClick={clear} aria-label="Clear selection">
151
+ <XIcon />
152
+ </Button>
153
+ </div>
154
+
155
+ <AlertDialog open={confirming} onOpenChange={setConfirming}>
156
+ <AlertDialogContent>
157
+ <AlertDialogHeader>
158
+ <AlertDialogTitle>
159
+ Delete {count} {noun}?
160
+ </AlertDialogTitle>
161
+ <AlertDialogDescription>This can&apos;t be undone. Anything that belongs to them may be deleted too.</AlertDialogDescription>
162
+ </AlertDialogHeader>
163
+ <AlertDialogFooter>
164
+ <AlertDialogCancel disabled={pending}>Keep them</AlertDialogCancel>
165
+ <AlertDialogAction onClick={(event) => { event.preventDefault(); remove(); }} disabled={pending}>
166
+ {pending && <Spinner data-icon="inline-start" />}
167
+ Delete {count}
168
+ </AlertDialogAction>
169
+ </AlertDialogFooter>
170
+ </AlertDialogContent>
171
+ </AlertDialog>
172
+ </>
173
+ );
174
+ }
175
+
176
+ /** Hand the browser a file without a round trip to the server. */
177
+ export function downloadCsv(filename: string, csv: string) {
178
+ // The BOM is what makes Excel read UTF-8 correctly.
179
+ const url = URL.createObjectURL(new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" }));
180
+ const link = document.createElement("a");
181
+ link.href = url;
182
+ link.download = filename;
183
+ link.click();
184
+ URL.revokeObjectURL(url);
185
+ }
@@ -0,0 +1,32 @@
1
+ import type { RequestPoint } from "@/lib/analytics";
2
+
3
+ /**
4
+ * Requests a day, with the failing share in red.
5
+ *
6
+ * Bars in CSS rather than a charting library: seven numbers don't justify shipping one
7
+ * to the browser, and this renders on the server with the rest of the page.
8
+ */
9
+ export function TrafficChart({ points }: { points: RequestPoint[] }) {
10
+ const peak = Math.max(1, ...points.map((point) => point.requests));
11
+ const day = new Intl.DateTimeFormat(undefined, { weekday: "short" });
12
+
13
+ return (
14
+ <div className="flex items-end gap-2" role="img" aria-label={`Requests a day for the last ${points.length} days`}>
15
+ {points.map((point) => {
16
+ const height = Math.max(2, Math.round((point.requests / peak) * 100));
17
+ const errorShare = point.requests > 0 ? (point.errors / point.requests) * 100 : 0;
18
+ return (
19
+ <div key={point.date} className="flex flex-1 flex-col items-center gap-2">
20
+ <span className="text-xs tabular-nums text-muted-foreground">{point.requests.toLocaleString()}</span>
21
+ <div className="flex h-32 w-full items-end" title={`${point.date}: ${point.requests.toLocaleString()} requests, ${point.errors.toLocaleString()} errors`}>
22
+ <div className="relative w-full overflow-hidden rounded-t-md bg-primary/80" style={{ height: `${height}%` }}>
23
+ {errorShare > 0 && <div className="absolute inset-x-0 bottom-0 bg-destructive" style={{ height: `${Math.max(4, errorShare)}%` }} />}
24
+ </div>
25
+ </div>
26
+ <span className="text-xs text-muted-foreground">{day.format(new Date(`${point.date}T00:00:00Z`))}</span>
27
+ </div>
28
+ );
29
+ })}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,70 @@
1
+ import { site } from "@/lib/site";
2
+
3
+ /**
4
+ * A drawing of the app in CSS, not a screenshot, so it takes the theme's colours and
5
+ * type. Replace it with a real screenshot of your product when you have one.
6
+ */
7
+ export function ProductPreview() {
8
+ const rows = [
9
+ ["Northwind", "Active", "$4,200"],
10
+ ["Brightline", "Trial", "$1,150"],
11
+ ["Vandelay", "Active", "$9,800"],
12
+ ["Globex", "Paused", "$640"],
13
+ ];
14
+ const bars = [38, 52, 44, 68, 57, 76, 71, 88, 80, 94, 86, 100];
15
+ return (
16
+ <div aria-hidden="true" className="relative mx-auto w-full max-w-5xl">
17
+ <div className="absolute -inset-x-10 -top-10 -bottom-20 -z-10 rounded-[3rem] bg-gradient-to-b from-brand/15 via-brand/5 to-transparent blur-2xl" />
18
+ <div className="overflow-hidden rounded-[calc(var(--radius)+10px)] border border-border bg-surface shadow-2xl shadow-foreground/10">
19
+ <div className="flex items-center gap-2 border-b border-border px-4 py-3">
20
+ <span className="size-3 rounded-full bg-border" />
21
+ <span className="size-3 rounded-full bg-border" />
22
+ <span className="size-3 rounded-full bg-border" />
23
+ <span className="ml-3 h-6 flex-1 rounded-md bg-surface-muted" />
24
+ </div>
25
+ <div className="grid grid-cols-[180px_1fr] max-sm:grid-cols-1">
26
+ <div className="flex flex-col gap-1.5 border-r border-border bg-surface-muted/60 p-4 max-sm:hidden">
27
+ <span className="mb-3 flex items-center gap-2 text-sm font-semibold">
28
+ <span className="size-5 rounded-md bg-brand [background-image:var(--brand-gradient,none)]" />
29
+ {site.name}
30
+ </span>
31
+ {["Overview", "Customers", "Invoices", "Reports", "Settings"].map((item, index) => (
32
+ <span key={item} className={`rounded-md px-2.5 py-1.5 text-xs ${index === 0 ? "bg-surface font-medium text-foreground shadow-sm" : "text-foreground-muted"}`}>
33
+ {item}
34
+ </span>
35
+ ))}
36
+ </div>
37
+ <div className="flex flex-col gap-5 p-6">
38
+ <div className="grid grid-cols-3 gap-3">
39
+ {[
40
+ ["Revenue", "$48,290", "+12%"],
41
+ ["Customers", "1,284", "+8%"],
42
+ ["Churn", "1.9%", "-0.4%"],
43
+ ].map(([label, value, change]) => (
44
+ <div key={label} className="rounded-[var(--radius)] border border-border p-3">
45
+ <p className="text-[11px] text-foreground-muted">{label}</p>
46
+ <p className="mt-1 text-lg font-semibold tabular-nums">{value}</p>
47
+ <p className="text-[11px] text-success">{change}</p>
48
+ </div>
49
+ ))}
50
+ </div>
51
+ <div className="flex h-28 items-end gap-1.5 rounded-[var(--radius)] border border-border p-3">
52
+ {bars.map((height, index) => (
53
+ <span key={index} className="flex-1 rounded-t-sm bg-brand/80 [background-image:var(--brand-gradient,none)]" style={{ height: `${height}%`, opacity: 0.35 + index * 0.055 }} />
54
+ ))}
55
+ </div>
56
+ <div className="rounded-[var(--radius)] border border-border">
57
+ {rows.map(([name, status, amount]) => (
58
+ <div key={name} className="flex items-center justify-between border-b border-border px-3 py-2 text-xs last:border-b-0">
59
+ <span className="font-medium">{name}</span>
60
+ <span className="rounded-full bg-surface-muted px-2 py-0.5 text-foreground-muted">{status}</span>
61
+ <span className="tabular-nums">{amount}</span>
62
+ </div>
63
+ ))}
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,39 @@
1
+ import Link from "next/link";
2
+ import { BrandMark } from "@/components/auth/auth-shell";
3
+ import { site } from "@/lib/site";
4
+
5
+ /** The public site's top bar. Static on purpose: it doesn't read the session, so the home page can be cached. */
6
+ export function SiteHeader() {
7
+ return (
8
+ <header className="sticky top-0 z-30 border-b border-border/60 bg-background/80 backdrop-blur-md">
9
+ <div className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-6 px-6">
10
+ <Link href="/" className="flex items-center gap-2.5 font-semibold">
11
+ <BrandMark link={false} className="[&_span_span]:size-8 [&_span_span]:text-base" />
12
+ {site.name}
13
+ </Link>
14
+ <nav aria-label="Main" className="hidden items-center gap-7 text-sm text-foreground-muted md:flex">
15
+ <a href="#features" className="hover:text-foreground">
16
+ Features
17
+ </a>
18
+ <a href="#customers" className="hover:text-foreground">
19
+ Customers
20
+ </a>
21
+ <a href={site.links.support} className="hover:text-foreground">
22
+ Contact
23
+ </a>
24
+ </nav>
25
+ <div className="flex items-center gap-2">
26
+ <Link href="/sign-in" className="rounded-[var(--button-radius)] px-3 py-2 text-sm font-medium text-foreground-muted hover:text-foreground">
27
+ Log in
28
+ </Link>
29
+ <Link
30
+ href="/sign-up"
31
+ className="rounded-[var(--button-radius)] bg-brand px-4 py-2 text-sm font-medium text-brand-foreground [background-image:var(--brand-gradient,none)] hover:brightness-95"
32
+ >
33
+ Sign up
34
+ </Link>
35
+ </div>
36
+ </div>
37
+ </header>
38
+ );
39
+ }
@@ -10,7 +10,7 @@ export function SignOutButton() {
10
10
  await authClient.signOut();
11
11
  window.location.assign("/sign-in");
12
12
  }}
13
- className="rounded-md border border-[var(--border)] px-3 py-1.5 text-sm font-medium hover:bg-[var(--surface-muted)]"
13
+ className="rounded-[var(--button-radius)] border border-border bg-surface px-3 py-1.5 text-sm font-medium hover:bg-surface-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
14
14
  >
15
15
  Sign out
16
16
  </button>