@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,132 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
8
+ import { Spinner } from "@/components/ui/spinner";
9
+ import { cn } from "@/lib/utils";
10
+ import type { RelationMeta } from "./field-widget";
11
+
12
+ interface Option {
13
+ id: string;
14
+ title: string;
15
+ }
16
+
17
+ interface Props {
18
+ id: string;
19
+ value: string;
20
+ onChange: (value: string) => void;
21
+ invalid: boolean;
22
+ disabled?: boolean;
23
+ required: boolean;
24
+ relation: RelationMeta & { initialTitle?: string };
25
+ }
26
+
27
+ /**
28
+ * Picker for a `belongsTo` field: searches the related resource through its generated
29
+ * REST API (so the same authorization applies) and stores the chosen record's id.
30
+ */
31
+ export function RelationField({ id, value, onChange, invalid, disabled, required, relation }: Props) {
32
+ const [open, setOpen] = useState(false);
33
+ const [query, setQuery] = useState("");
34
+ const [options, setOptions] = useState<Option[]>([]);
35
+ const [loading, setLoading] = useState(false);
36
+ const [title, setTitle] = useState(relation.initialTitle ?? "");
37
+
38
+ useEffect(() => {
39
+ if (!open) return;
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(async () => {
42
+ setLoading(true);
43
+ try {
44
+ const search = new URLSearchParams({ perPage: "20", sort: relation.titleField });
45
+ if (query.trim()) search.set("q", query.trim());
46
+ const response = await fetch(`/api/${relation.slug}?${search}`, { signal: controller.signal, credentials: "same-origin" });
47
+ if (!response.ok) throw new Error(String(response.status));
48
+ const body = (await response.json()) as { data: Record<string, unknown>[] };
49
+ setOptions(body.data.map((row) => ({ id: String(row.id), title: String(row[relation.titleField] ?? row.id) })));
50
+ } catch (error) {
51
+ if ((error as Error).name !== "AbortError") setOptions([]);
52
+ } finally {
53
+ setLoading(false);
54
+ }
55
+ }, 200);
56
+ return () => {
57
+ controller.abort();
58
+ clearTimeout(timer);
59
+ };
60
+ }, [open, query, relation.slug, relation.titleField]);
61
+
62
+ return (
63
+ <div className="flex items-center gap-1">
64
+ <Popover open={open} onOpenChange={setOpen}>
65
+ <PopoverTrigger asChild>
66
+ <Button
67
+ id={id}
68
+ type="button"
69
+ variant="outline"
70
+ role="combobox"
71
+ aria-expanded={open}
72
+ aria-invalid={invalid || undefined}
73
+ disabled={disabled}
74
+ className={cn("w-full justify-between font-normal", !value && "text-muted-foreground")}
75
+ >
76
+ {value ? (title || value) : `Choose ${relation.label.toLowerCase()}`}
77
+ <ChevronsUpDownIcon className="opacity-50" />
78
+ </Button>
79
+ </PopoverTrigger>
80
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
81
+ <Command shouldFilter={false}>
82
+ <CommandInput
83
+ placeholder={`Search ${relation.pluralLabel.toLowerCase()}…`}
84
+ value={query}
85
+ onValueChange={setQuery}
86
+ />
87
+ <CommandList>
88
+ {loading ? (
89
+ <div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
90
+ <Spinner /> Searching…
91
+ </div>
92
+ ) : (
93
+ <CommandEmpty>No {relation.pluralLabel.toLowerCase()} found.</CommandEmpty>
94
+ )}
95
+ <CommandGroup>
96
+ {options.map((option) => (
97
+ <CommandItem
98
+ key={option.id}
99
+ value={option.id}
100
+ onSelect={() => {
101
+ onChange(option.id);
102
+ setTitle(option.title);
103
+ setOpen(false);
104
+ }}
105
+ >
106
+ {option.title}
107
+ <CheckIcon className={cn("ml-auto", option.id === value ? "opacity-100" : "opacity-0")} />
108
+ </CommandItem>
109
+ ))}
110
+ </CommandGroup>
111
+ </CommandList>
112
+ </Command>
113
+ </PopoverContent>
114
+ </Popover>
115
+ {value && !required && (
116
+ <Button
117
+ type="button"
118
+ variant="ghost"
119
+ size="icon"
120
+ aria-label={`Clear ${relation.label.toLowerCase()}`}
121
+ disabled={disabled}
122
+ onClick={() => {
123
+ onChange("");
124
+ setTitle("");
125
+ }}
126
+ >
127
+ <XIcon />
128
+ </Button>
129
+ )}
130
+ </div>
131
+ );
132
+ }
@@ -0,0 +1,25 @@
1
+ export type SearchParams = Record<string, string | string[] | undefined>;
2
+
3
+ export function toSearchParams(params: SearchParams): URLSearchParams {
4
+ const search = new URLSearchParams();
5
+ for (const [key, value] of Object.entries(params)) {
6
+ if (Array.isArray(value)) value.forEach((item) => search.append(key, item));
7
+ else if (value !== undefined) search.set(key, value);
8
+ }
9
+ return search;
10
+ }
11
+
12
+ /**
13
+ * `path?query` with `changes` applied (`null` removes a param). Changing anything other
14
+ * than the page resets pagination, so results never start on a page that no longer exists.
15
+ */
16
+ export function hrefWith(path: string, current: URLSearchParams, changes: Record<string, string | null>): string {
17
+ const next = new URLSearchParams(current);
18
+ for (const [key, value] of Object.entries(changes)) {
19
+ if (value === null || value === "") next.delete(key);
20
+ else next.set(key, value);
21
+ }
22
+ if (!("page" in changes)) next.delete("page");
23
+ const query = next.toString();
24
+ return query ? `${path}?${query}` : path;
25
+ }
@@ -0,0 +1,55 @@
1
+ import { notFound } from "next/navigation";
2
+ import { formatValue, storedFields, type Resource } from "@flaredev/core";
3
+ import { adminPath, adminResources, adminStore, requireAccess } from "@/lib/admin";
4
+ import type { RelationMeta } from "./fields/field-widget";
5
+ import { ResourceForm } from "./resource-form";
6
+
7
+ /** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
8
+ export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
9
+ await requireAccess(resource, id ? "update" : "create");
10
+ let record: Record<string, unknown> | null = null;
11
+ if (id) {
12
+ const result = await adminStore(resource.name).get(id);
13
+ if (!result.ok) notFound();
14
+ record = result.data;
15
+ }
16
+
17
+ const byName = new Map(adminResources().map((r) => [r.name, r]));
18
+ const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
19
+ for (const [key, def] of storedFields(resource)) {
20
+ if (def.kind !== "belongsTo") continue;
21
+ const target = byName.get(def.target);
22
+ if (!target) continue;
23
+ const value = record?.[key];
24
+ const titles = typeof value === "string" ? await adminStore(target.name).titles([value]) : {};
25
+ relations[key] = {
26
+ name: target.name,
27
+ label: target.label,
28
+ pluralLabel: target.pluralLabel,
29
+ slug: target.slug,
30
+ titleField: target.titleField,
31
+ initialTitle: typeof value === "string" ? titles[value] : undefined,
32
+ };
33
+ }
34
+
35
+ const titleDef = resource.fields[resource.titleField];
36
+ const title =
37
+ record && titleDef && titleDef.kind !== "hasMany" ? formatValue(titleDef, record[resource.titleField]) : `New ${resource.label.toLowerCase()}`;
38
+
39
+ return (
40
+ <div className="flex flex-col gap-6">
41
+ <div className="flex flex-col gap-1">
42
+ <h1 className="text-2xl font-semibold">{title}</h1>
43
+ {record && <p className="text-xs text-muted-foreground">Edit {resource.label.toLowerCase()}</p>}
44
+ </div>
45
+ <ResourceForm
46
+ resource={resource}
47
+ mode={record ? "edit" : "create"}
48
+ id={id}
49
+ record={record}
50
+ relations={relations}
51
+ listHref={adminPath(resource)}
52
+ />
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,139 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useMemo, useState, useTransition, type FormEvent } from "react";
6
+ import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type Resource } from "@flaredev/core";
7
+ import { toast } from "sonner";
8
+ import { createRecordAction, updateRecordAction } from "@/app/admin/actions";
9
+ import { Alert, AlertDescription } from "@/components/ui/alert";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Card, CardContent, CardFooter } from "@/components/ui/card";
12
+ import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
13
+ import { Spinner } from "@/components/ui/spinner";
14
+ import { FieldWidget, type RelationMeta } from "./fields/field-widget";
15
+
16
+ export interface ResourceFormProps {
17
+ resource: Resource;
18
+ mode: "create" | "edit";
19
+ /** Record id (edit mode). */
20
+ id?: string;
21
+ record?: Record<string, unknown> | null;
22
+ /** belongsTo field key → related resource metadata and the current value's title. */
23
+ relations?: Record<string, RelationMeta & { initialTitle?: string }>;
24
+ /** Where Cancel and a successful save go. */
25
+ listHref: string;
26
+ }
27
+
28
+ const WIDE = new Set(["text", "file"]);
29
+
30
+ /**
31
+ * Create/edit form rendered from field metadata. Values are validated in the browser
32
+ * with the descriptor's zod schemas, then again on the server by the same store the
33
+ * REST API uses; server errors (e.g. unique conflicts) land on their fields.
34
+ */
35
+ export function ResourceForm({ resource, mode, id, record, relations = {}, listHref }: ResourceFormProps) {
36
+ const router = useRouter();
37
+ const validators = useMemo(() => createValidators(resource), [resource]);
38
+ const fields = useMemo(() => storedFields(resource), [resource]);
39
+ const [values, setValues] = useState(() => initialFormValues(resource, record));
40
+ const [errors, setErrors] = useState<Record<string, string>>({});
41
+ const [pending, startTransition] = useTransition();
42
+
43
+ const setValue = (key: string, value: string | boolean) => {
44
+ setValues((current) => ({ ...current, [key]: value }));
45
+ if (errors[key]) setErrors(({ [key]: _removed, ...rest }) => rest);
46
+ };
47
+
48
+ function focusFirstError(fieldErrors: Record<string, string>) {
49
+ const first = fields.find(([key]) => fieldErrors[key]);
50
+ if (first) document.getElementById(`field-${first[0]}`)?.focus();
51
+ }
52
+
53
+ function onSubmit(event: FormEvent) {
54
+ event.preventDefault();
55
+ const input = formValuesToInput(resource, values, mode);
56
+ const parsed = (mode === "create" ? validators.create : validators.update).safeParse(input);
57
+ if (!parsed.success) {
58
+ const fieldErrors = issuesByField(parsed.error.issues);
59
+ setErrors(fieldErrors);
60
+ focusFirstError(fieldErrors);
61
+ return;
62
+ }
63
+
64
+ startTransition(async () => {
65
+ const result = mode === "create" ? await createRecordAction(resource.name, input) : await updateRecordAction(resource.name, id!, input);
66
+ if (result.ok) {
67
+ toast.success(`${resource.label} ${mode === "create" ? "created" : "saved"}.`);
68
+ router.push(listHref);
69
+ router.refresh();
70
+ return;
71
+ }
72
+ const fieldErrors = result.issues ? issuesByField(result.issues) : result.field ? { [result.field]: result.error } : { _form: result.error };
73
+ setErrors(fieldErrors);
74
+ focusFirstError(fieldErrors);
75
+ });
76
+ }
77
+
78
+ return (
79
+ <form onSubmit={onSubmit} noValidate>
80
+ <Card>
81
+ <CardContent>
82
+ {errors._form && (
83
+ <Alert variant="destructive" className="mb-6">
84
+ <AlertDescription>{errors._form}</AlertDescription>
85
+ </Alert>
86
+ )}
87
+ <FieldGroup className="grid gap-6 md:grid-cols-2">
88
+ {fields.map(([key, def]) => {
89
+ const error = errors[key];
90
+ const widgetId = `field-${key}`;
91
+ const horizontal = def.kind === "boolean";
92
+ return (
93
+ <Field
94
+ key={key}
95
+ data-invalid={error ? true : undefined}
96
+ className={WIDE.has(def.kind) ? "md:col-span-2" : undefined}
97
+ >
98
+ <FieldLabel htmlFor={widgetId}>
99
+ {def.label}
100
+ {def.required && def.kind !== "boolean" && (
101
+ <span className="ml-0.5 text-muted-foreground" aria-hidden>
102
+ *
103
+ </span>
104
+ )}
105
+ </FieldLabel>
106
+ <div className={horizontal ? "flex h-9 items-center" : undefined}>
107
+ <FieldWidget
108
+ id={widgetId}
109
+ name={key}
110
+ resourceName={resource.name}
111
+ fieldKey={key}
112
+ field={def}
113
+ value={values[key] ?? ""}
114
+ onChange={(value) => setValue(key, value)}
115
+ invalid={Boolean(error)}
116
+ disabled={pending}
117
+ relation={relations[key]}
118
+ />
119
+ </div>
120
+ {def.helpText && <FieldDescription>{def.helpText}</FieldDescription>}
121
+ {error && <FieldError>{error}</FieldError>}
122
+ </Field>
123
+ );
124
+ })}
125
+ </FieldGroup>
126
+ </CardContent>
127
+ <CardFooter className="justify-end gap-2 border-t">
128
+ <Button variant="outline" asChild>
129
+ <Link href={listHref}>Cancel</Link>
130
+ </Button>
131
+ <Button type="submit" disabled={pending}>
132
+ {pending && <Spinner data-icon="inline-start" />}
133
+ {mode === "create" ? `Create ${resource.label.toLowerCase()}` : "Save changes"}
134
+ </Button>
135
+ </CardFooter>
136
+ </Card>
137
+ </form>
138
+ );
139
+ }
@@ -0,0 +1,85 @@
1
+ import {
2
+ BellIcon,
3
+ BookIcon,
4
+ BoxIcon,
5
+ BriefcaseIcon,
6
+ Building2Icon,
7
+ CalendarIcon,
8
+ ChartBarIcon,
9
+ ClipboardListIcon,
10
+ CreditCardIcon,
11
+ DatabaseIcon,
12
+ DollarSignIcon,
13
+ FileTextIcon,
14
+ FolderIcon,
15
+ GlobeIcon,
16
+ HeartIcon,
17
+ ImageIcon,
18
+ LayersIcon,
19
+ MailIcon,
20
+ MapPinIcon,
21
+ MessageSquareIcon,
22
+ PackageIcon,
23
+ PhoneIcon,
24
+ ReceiptIcon,
25
+ SettingsIcon,
26
+ ShieldAlertIcon,
27
+ RadioIcon,
28
+ ShieldIcon,
29
+ ShoppingCartIcon,
30
+ StarIcon,
31
+ TagIcon,
32
+ TicketIcon,
33
+ TruckIcon,
34
+ UserIcon,
35
+ UsersIcon,
36
+ WrenchIcon,
37
+ type LucideIcon,
38
+ } from "lucide-react";
39
+
40
+ /**
41
+ * Icons a resource descriptor can name (`icon: "users"`). Imported by name so unused
42
+ * ones are tree-shaken; add the lucide icons your app needs.
43
+ */
44
+ export const RESOURCE_ICONS: Record<string, LucideIcon> = {
45
+ bell: BellIcon,
46
+ book: BookIcon,
47
+ box: BoxIcon,
48
+ briefcase: BriefcaseIcon,
49
+ building: Building2Icon,
50
+ calendar: CalendarIcon,
51
+ chart: ChartBarIcon,
52
+ clipboard: ClipboardListIcon,
53
+ "credit-card": CreditCardIcon,
54
+ database: DatabaseIcon,
55
+ dollar: DollarSignIcon,
56
+ file: FileTextIcon,
57
+ folder: FolderIcon,
58
+ globe: GlobeIcon,
59
+ heart: HeartIcon,
60
+ image: ImageIcon,
61
+ layers: LayersIcon,
62
+ mail: MailIcon,
63
+ "map-pin": MapPinIcon,
64
+ message: MessageSquareIcon,
65
+ package: PackageIcon,
66
+ phone: PhoneIcon,
67
+ receipt: ReceiptIcon,
68
+ settings: SettingsIcon,
69
+ radio: RadioIcon,
70
+ shield: ShieldIcon,
71
+ "shield-alert": ShieldAlertIcon,
72
+ cart: ShoppingCartIcon,
73
+ star: StarIcon,
74
+ tag: TagIcon,
75
+ ticket: TicketIcon,
76
+ truck: TruckIcon,
77
+ user: UserIcon,
78
+ users: UsersIcon,
79
+ wrench: WrenchIcon,
80
+ };
81
+
82
+ /** The icon for a resource, falling back to a generic document icon. */
83
+ export function resourceIcon(name: string | undefined): LucideIcon {
84
+ return (name && RESOURCE_ICONS[name]) || FileTextIcon;
85
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { usePathname } from "next/navigation";
5
+ import { LayoutDashboardIcon } from "lucide-react";
6
+ import {
7
+ Sidebar,
8
+ SidebarContent,
9
+ SidebarGroup,
10
+ SidebarGroupContent,
11
+ SidebarGroupLabel,
12
+ SidebarHeader,
13
+ SidebarMenu,
14
+ SidebarMenuButton,
15
+ SidebarMenuItem,
16
+ } from "@/components/ui/sidebar";
17
+ import { resourceIcon } from "./resource-icon";
18
+
19
+ export interface NavResource {
20
+ name: string;
21
+ pluralLabel: string;
22
+ slug: string;
23
+ icon?: string;
24
+ /** Optional count badge (off by default). */
25
+ badge?: number;
26
+ }
27
+
28
+ /**
29
+ * Sidebar navigation, one item per registered resource. The icon comes from the
30
+ * descriptor's `icon`, the label from its plural label, and the active item from the
31
+ * current path.
32
+ */
33
+ export function ResourceNav({
34
+ appName,
35
+ resources,
36
+ links = [],
37
+ }: {
38
+ appName: string;
39
+ resources: NavResource[];
40
+ /** Extra pages (see lib/admin-nav.ts), shown under "Platform". */
41
+ links?: Array<{ label: string; href: string; icon: string }>;
42
+ }) {
43
+ const pathname = usePathname();
44
+
45
+ return (
46
+ <Sidebar collapsible="icon">
47
+ <SidebarHeader>
48
+ <SidebarMenu>
49
+ <SidebarMenuItem>
50
+ <SidebarMenuButton asChild size="lg" tooltip={appName}>
51
+ <Link href="/admin">
52
+ <div className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground">
53
+ <LayoutDashboardIcon className="size-4" />
54
+ </div>
55
+ <span className="truncate font-semibold">{appName}</span>
56
+ </Link>
57
+ </SidebarMenuButton>
58
+ </SidebarMenuItem>
59
+ </SidebarMenu>
60
+ </SidebarHeader>
61
+
62
+ <SidebarContent>
63
+ <SidebarGroup>
64
+ <SidebarGroupLabel>Resources</SidebarGroupLabel>
65
+ <SidebarGroupContent>
66
+ <SidebarMenu>
67
+ {resources.map((resource) => {
68
+ const href = `/admin/${resource.slug}`;
69
+ const Icon = resourceIcon(resource.icon);
70
+ return (
71
+ <SidebarMenuItem key={resource.name}>
72
+ <SidebarMenuButton asChild isActive={pathname === href || pathname.startsWith(`${href}/`)} tooltip={resource.pluralLabel}>
73
+ <Link href={href}>
74
+ <Icon />
75
+ <span>{resource.pluralLabel}</span>
76
+ </Link>
77
+ </SidebarMenuButton>
78
+ </SidebarMenuItem>
79
+ );
80
+ })}
81
+ </SidebarMenu>
82
+ </SidebarGroupContent>
83
+ </SidebarGroup>
84
+ {links.length > 0 && (
85
+ <SidebarGroup>
86
+ <SidebarGroupLabel>Platform</SidebarGroupLabel>
87
+ <SidebarGroupContent>
88
+ <SidebarMenu>
89
+ {links.map((link) => {
90
+ const Icon = resourceIcon(link.icon);
91
+ return (
92
+ <SidebarMenuItem key={link.href}>
93
+ <SidebarMenuButton asChild isActive={pathname === link.href || pathname.startsWith(`${link.href}/`)} tooltip={link.label}>
94
+ <Link href={link.href}>
95
+ <Icon />
96
+ <span>{link.label}</span>
97
+ </Link>
98
+ </SidebarMenuButton>
99
+ </SidebarMenuItem>
100
+ );
101
+ })}
102
+ </SidebarMenu>
103
+ </SidebarGroupContent>
104
+ </SidebarGroup>
105
+ )}
106
+ </SidebarContent>
107
+ </Sidebar>
108
+ );
109
+ }
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
4
+ import { useState, type FormEvent } from "react";
5
+ import { SearchIcon, XIcon } from "lucide-react";
6
+ import { optionLabel, storedFields, type Resource } from "@flaredev/core";
7
+ import { Button } from "@/components/ui/button";
8
+ import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
9
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
10
+ import { hrefWith } from "./query";
11
+
12
+ const ALL = "__all__";
13
+
14
+ /** Search box and enum/boolean filters for ResourceTable; all state goes into the URL. */
15
+ export function ResourceTableToolbar({ resource }: { resource: Resource }) {
16
+ const router = useRouter();
17
+ const pathname = usePathname();
18
+ const params = new URLSearchParams(useSearchParams().toString());
19
+ const [q, setQ] = useState(params.get("q") ?? "");
20
+
21
+ const fields = storedFields(resource);
22
+ const searchable = fields.some(([, def]) => def.searchable ?? def.kind === "string");
23
+ // Relation filters need a lookup UI; the toolbar offers the ones with fixed options.
24
+ const filters = fields.filter(([, def]) => (def.filterable ?? ["enum", "boolean"].includes(def.kind)) && ["enum", "boolean"].includes(def.kind));
25
+ const hasActive = params.has("q") || [...params.keys()].some((key) => key.startsWith("filter["));
26
+
27
+ const go = (changes: Record<string, string | null>) => router.push(hrefWith(pathname, params, changes));
28
+
29
+ function onSearch(event: FormEvent) {
30
+ event.preventDefault();
31
+ go({ q: q.trim() || null });
32
+ }
33
+
34
+ if (!searchable && filters.length === 0) return null;
35
+
36
+ return (
37
+ <div className="flex flex-wrap items-center gap-2">
38
+ {searchable && (
39
+ <form onSubmit={onSearch} role="search" className="w-full sm:w-72">
40
+ <InputGroup>
41
+ <InputGroupAddon>
42
+ <SearchIcon />
43
+ </InputGroupAddon>
44
+ <InputGroupInput
45
+ type="search"
46
+ value={q}
47
+ onChange={(event) => setQ(event.target.value)}
48
+ placeholder={`Search ${resource.pluralLabel.toLowerCase()}…`}
49
+ aria-label={`Search ${resource.pluralLabel.toLowerCase()}`}
50
+ />
51
+ </InputGroup>
52
+ </form>
53
+ )}
54
+
55
+ {filters.map(([key, def]) => {
56
+ const param = `filter[${key}]`;
57
+ const options = def.kind === "enum" ? def.options.map((value) => ({ value, label: optionLabel(def, value) })) : [
58
+ { value: "true", label: "Yes" },
59
+ { value: "false", label: "No" },
60
+ ];
61
+ return (
62
+ <Select key={key} value={params.get(param) ?? ALL} onValueChange={(value) => go({ [param]: value === ALL ? null : value })}>
63
+ <SelectTrigger className="min-w-36" aria-label={`Filter by ${def.label}`}>
64
+ <span className="text-muted-foreground">{def.label}:</span>
65
+ <SelectValue />
66
+ </SelectTrigger>
67
+ <SelectContent>
68
+ <SelectGroup>
69
+ <SelectItem value={ALL}>Any</SelectItem>
70
+ {options.map((option) => (
71
+ <SelectItem key={option.value} value={option.value}>
72
+ {option.label}
73
+ </SelectItem>
74
+ ))}
75
+ </SelectGroup>
76
+ </SelectContent>
77
+ </Select>
78
+ );
79
+ })}
80
+
81
+ {hasActive && (
82
+ <Button
83
+ variant="ghost"
84
+ onClick={() => {
85
+ setQ("");
86
+ router.push(pathname);
87
+ }}
88
+ >
89
+ <XIcon data-icon="inline-start" />
90
+ Clear
91
+ </Button>
92
+ )}
93
+ </div>
94
+ );
95
+ }