@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,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
|
+
}
|