@flaredev/cli 0.4.0 → 0.4.1

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.
@@ -10,7 +10,7 @@ import { getDb } from "@/db";
10
10
  import { savedView } from "@/db/flare-schema";
11
11
  import { toCsv } from "@/lib/csv";
12
12
  import { storage } from "@/lib/storage";
13
- import { can, storedFields, type PolicyAction } from "@flaredev/core";
13
+ import { can, storedFields, type PolicyAction, type Resource } from "@flaredev/core";
14
14
  import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
15
15
 
16
16
  export type ActionResult<T = unknown> =
@@ -26,6 +26,23 @@ async function allowed(resourceName: string, action: PolicyAction): Promise<Acti
26
26
  if (!can(policyFor(resourceName), role, action)) return forbidden(`Your role can't ${action} this record.`);
27
27
  }
28
28
 
29
+ /**
30
+ * Take a deleted record's uploads out of storage.
31
+ *
32
+ * The row is gone, so nothing can point at those objects again, and an app that never
33
+ * removes them pays to keep files no one can reach. A failure here is swallowed on
34
+ * purpose: the record is already deleted, and a stray object is a smaller problem than
35
+ * an action that reports failure after having done the work.
36
+ */
37
+ async function removeUploads(resource: Resource, record: Record<string, unknown> | null | undefined): Promise<void> {
38
+ if (!record) return;
39
+ for (const [key, def] of storedFields(resource)) {
40
+ if (def.kind !== "file") continue;
41
+ const value = record[key];
42
+ if (typeof value === "string" && value) await storage.delete(value).catch(() => undefined);
43
+ }
44
+ }
45
+
29
46
  export async function deleteRecordAction(resourceName: string, id: string): Promise<ActionResult<{ id: string }>> {
30
47
  const denied = await allowed(resourceName, "delete");
31
48
  if (denied) return denied;
@@ -34,6 +51,7 @@ export async function deleteRecordAction(resourceName: string, id: string): Prom
34
51
  const existing = await store.get(id);
35
52
  const result = await store.delete(id);
36
53
  if (result.ok) {
54
+ await removeUploads(store.resource, existing.ok ? existing.data : null);
37
55
  await recordAudit({
38
56
  action: "delete",
39
57
  resource: resourceName,
@@ -67,10 +85,15 @@ export async function deleteManyAction(resourceName: string, ids: string[]): Pro
67
85
  // at it) shouldn't take the rest of the batch with it.
68
86
  let deleted = 0;
69
87
  let failed = 0;
88
+ const hasFiles = storedFields(store.resource).some(([, def]) => def.kind === "file");
70
89
  for (const id of ids) {
90
+ // Only read the record first when there's an upload to clean up afterwards.
91
+ const existing = hasFiles ? await store.get(id) : null;
71
92
  const result = await store.delete(id);
72
- if (result.ok) deleted++;
73
- else failed++;
93
+ if (result.ok) {
94
+ deleted++;
95
+ if (existing?.ok) await removeUploads(store.resource, existing.data);
96
+ } else failed++;
74
97
  }
75
98
  if (deleted > 0) {
76
99
  await recordAudit({ action: "bulk-delete", resource: resourceName, changes: { deleted, failed } });
@@ -233,7 +256,17 @@ export async function createUploadUrlAction(
233
256
  * field's prefix are signed, and only for roles that can work with the resource, so a
234
257
  * guessed or borrowed key can't be read through a resource the role can't see.
235
258
  */
236
- export async function createReadUrlAction(resourceName: string, fieldKey: string, key: string): Promise<ActionResult<{ url: string }>> {
259
+ export async function createReadUrlAction(
260
+ resourceName: string,
261
+ fieldKey: string,
262
+ key: string,
263
+ /**
264
+ * Serve the file as a download with this name instead of showing it in the tab. Object
265
+ * keys carry an id to keep them unique, so without this a download arrives called
266
+ * `3f1c…-invoice.pdf` — pass the name the record holds.
267
+ */
268
+ downloadAs?: string,
269
+ ): Promise<ActionResult<{ url: string }>> {
237
270
  const denied = await allowed(resourceName, "read");
238
271
  // Someone creating or editing a record can view what they just uploaded.
239
272
  if (denied && (await allowed(resourceName, "create")) && (await allowed(resourceName, "update"))) return denied;
@@ -242,5 +275,5 @@ export async function createReadUrlAction(resourceName: string, fieldKey: string
242
275
  if ("failure" in target) return target.failure;
243
276
  if (!key.startsWith(target.prefix)) return { ok: false, status: 403, error: "That file doesn't belong to this field.", field: fieldKey };
244
277
 
245
- return { ok: true, data: { url: await storage.createReadUrl({ key }) } };
278
+ return { ok: true, data: { url: await storage.createReadUrl({ key, downloadAs: downloadAs || undefined }) } };
246
279
  }
@@ -1,11 +1,5 @@
1
1
  @import "tailwindcss";
2
2
  @import "tw-animate-css";
3
- /* One typeface per theme (self-hosted; only the active theme's font is downloaded). */
4
- @import "@fontsource-variable/inter";
5
- @import "@fontsource-variable/plus-jakarta-sans";
6
- @import "@fontsource-variable/noto-sans";
7
- @import "@fontsource-variable/geist";
8
- @import "@fontsource-variable/figtree";
9
3
 
10
4
  /*
11
5
  * Dark mode: the `dark` class on <html> (set from the theme cookie) or, with no
@@ -1,28 +1,37 @@
1
- import type { Metadata } from "next";
2
- import { site } from "@/lib/site";
3
- import { activeTheme } from "@/lib/theme";
4
- import "./globals.css";
5
-
6
- export const metadata: Metadata = {
7
- title: { default: site.name, template: `%s · ${site.name}` },
8
- description: site.description,
9
- };
10
-
11
- /**
12
- * Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
13
- * first paint. It runs in the browser rather than reading cookies() here: a layout
14
- * that reads cookies makes every page under it dynamic, and dynamic pages can never
15
- * be served from the CDN cache.
16
- */
17
- const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
18
-
19
- export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
20
- return (
21
- <html lang="en" data-theme={activeTheme} suppressHydrationWarning>
22
- <head>
23
- <script dangerouslySetInnerHTML={{ __html: themeScript }} />
24
- </head>
25
- <body>{children}</body>
26
- </html>
27
- );
28
- }
1
+ import type { Metadata } from "next";
2
+ import { site } from "@/lib/site";
3
+ import { activeTheme } from "@/lib/theme";
4
+ import "./globals.css";
5
+ // One typeface per theme, self-hosted; the browser only downloads the one in use.
6
+ // These are imported here rather than from globals.css because Tailwind inlines a CSS
7
+ // @import, and the font files' relative URLs are then resolved against the wrong file
8
+ // and never emitted — which shows up only in a build, as every font 404ing.
9
+ import "@fontsource-variable/inter";
10
+ import "@fontsource-variable/plus-jakarta-sans";
11
+ import "@fontsource-variable/noto-sans";
12
+ import "@fontsource-variable/geist";
13
+ import "@fontsource-variable/figtree";
14
+
15
+ export const metadata: Metadata = {
16
+ title: { default: site.name, template: `%s · ${site.name}` },
17
+ description: site.description,
18
+ };
19
+
20
+ /**
21
+ * Applies the saved theme (the `flare-theme` cookie the admin's toggle writes) before
22
+ * first paint. It runs in the browser rather than reading cookies() here: a layout
23
+ * that reads cookies makes every page under it dynamic, and dynamic pages can never
24
+ * be served from the CDN cache.
25
+ */
26
+ const themeScript = `(function(){try{var m=document.cookie.match(/(?:^|; )flare-theme=(light|dark)/);if(m)document.documentElement.classList.add(m[1]);}catch(e){}})();`;
27
+
28
+ export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
29
+ return (
30
+ <html lang="en" data-theme={activeTheme} suppressHydrationWarning>
31
+ <head>
32
+ <script dangerouslySetInnerHTML={{ __html: themeScript }} />
33
+ </head>
34
+ <body>{children}</body>
35
+ </html>
36
+ );
37
+ }
@@ -48,6 +48,7 @@ function formatBytes(bytes: number): string {
48
48
 
49
49
  /** ["image", "pdf"] → "image or PDF" (acronyms stay upper case). */
50
50
  function describeAccept(accept: readonly string[]): string {
51
+ if (accept.includes("any")) return "any file";
51
52
  const names = accept.map((category) => (category === "pdf" || category === "csv" ? category.toUpperCase() : category));
52
53
  return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names.at(-1)}` : names[0]!;
53
54
  }
@@ -129,6 +130,9 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
129
130
  const problem = await check(file);
130
131
  if (problem) return setError(problem);
131
132
 
133
+ // A browser leaves `type` empty for an extension it doesn't know, and an upload has
134
+ // to say what it is sending, so fall back to the type that means "bytes".
135
+ const contentType = file.type || "application/octet-stream";
132
136
  const signal: { abort?: () => void } = {};
133
137
  const localPreview = IMAGE_TYPES.has(file.type) ? URL.createObjectURL(file) : null;
134
138
  setUpload({ name: file.name, loaded: 0, total: file.size, abort: () => signal.abort?.() });
@@ -136,7 +140,7 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
136
140
  setStatus(`Uploading ${file.name}…`);
137
141
 
138
142
  try {
139
- const signed = await createUploadUrlAction(resourceName, fieldKey, { name: file.name, type: file.type, size: file.size });
143
+ const signed = await createUploadUrlAction(resourceName, fieldKey, { name: file.name, type: contentType, size: file.size });
140
144
  if (!signed.ok) throw new Error(signed.error);
141
145
  await put(signed.data.url, file, (loaded) => setUpload((current) => current && { ...current, loaded }), signal);
142
146
  onChange(signed.data.key);
@@ -33,7 +33,29 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
33
33
  const [query, setQuery] = useState("");
34
34
  const [options, setOptions] = useState<Option[]>([]);
35
35
  const [loading, setLoading] = useState(false);
36
- const [title, setTitle] = useState(relation.initialTitle ?? "");
36
+ // The title is remembered against the id it belongs to, so a value that changes
37
+ // underneath the field doesn't keep the name of the record it used to hold.
38
+ const [known, setKnown] = useState({ id: value, title: relation.initialTitle ?? "" });
39
+ const title = known.id === value ? known.title : "";
40
+
41
+ // A form opened from a table row is given the id but not the name behind it. Looking it
42
+ // up here means the field reads correctly wherever the form was rendered.
43
+ useEffect(() => {
44
+ if (!value || title) return;
45
+ const controller = new AbortController();
46
+ void (async () => {
47
+ try {
48
+ const response = await fetch(`/api/${relation.slug}/${value}`, { signal: controller.signal, credentials: "same-origin" });
49
+ if (!response.ok) return;
50
+ const record = (await response.json()) as Record<string, unknown>;
51
+ const found = record[relation.titleField];
52
+ if (found) setKnown({ id: value, title: String(found) });
53
+ } catch {
54
+ // Leave the id showing; the field still works.
55
+ }
56
+ })();
57
+ return () => controller.abort();
58
+ }, [value, title, relation.slug, relation.titleField]);
37
59
 
38
60
  useEffect(() => {
39
61
  if (!open) return;
@@ -99,7 +121,7 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
99
121
  value={option.id}
100
122
  onSelect={() => {
101
123
  onChange(option.id);
102
- setTitle(option.title);
124
+ setKnown({ id: option.id, title: option.title });
103
125
  setOpen(false);
104
126
  }}
105
127
  >
@@ -121,7 +143,7 @@ export function RelationField({ id, value, onChange, invalid, disabled, required
121
143
  disabled={disabled}
122
144
  onClick={() => {
123
145
  onChange("");
124
- setTitle("");
146
+ setKnown({ id: "", title: "" });
125
147
  }}
126
148
  >
127
149
  <XIcon />
@@ -10,7 +10,7 @@ import {
10
10
  storedFields,
11
11
  type EnumField,
12
12
  type MultiSelectField,
13
- type Resource,
13
+ type ClientResource,
14
14
  type StoredField,
15
15
  } from "@flaredev/core";
16
16
  import { importRecordsAction } from "@/app/dashboard/import-actions";
@@ -202,7 +202,7 @@ function guessMapping(fields: MappedField[], headers: string[]): Record<string,
202
202
  * schemas and again on the server, and sent in batches so a long file reports progress
203
203
  * and never depends on one very long request.
204
204
  */
205
- export function ImportDialog({ resource }: { resource: Resource }) {
205
+ export function ImportDialog({ resource }: { resource: ClientResource }) {
206
206
  const router = useRouter();
207
207
  const validators = useMemo(() => createValidators(resource), [resource]);
208
208
  // Uploads need a signed URL per file, which a CSV cell can't stand in for.
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
5
5
  import { useState, useTransition } from "react";
6
6
  import { PencilIcon, Trash2Icon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
- import type { Resource } from "@flaredev/core";
8
+ import type { ClientResource } from "@flaredev/core";
9
9
  import { deleteRecordAction } from "@/app/dashboard/actions";
10
10
  import {
11
11
  AlertDialog,
@@ -21,7 +21,7 @@ import { Spinner } from "@/components/ui/spinner";
21
21
  import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
22
22
 
23
23
  interface Props {
24
- resource: Resource;
24
+ resource: ClientResource;
25
25
  id: string;
26
26
  record: Record<string, unknown>;
27
27
  relations: FormRelations;
@@ -1,6 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import { notFound } from "next/navigation";
3
- import { formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
3
+ import { clientResource, formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
4
4
  import { Badge } from "@/components/ui/badge";
5
5
  import { Button } from "@/components/ui/button";
6
6
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -23,6 +23,9 @@ const RELATED_LIMIT = 5;
23
23
  */
24
24
  export async function RecordDetail({ resource, id }: { resource: Resource; id: string }) {
25
25
  await requireAccess(resource, "read");
26
+ // Hooks and computed values are functions, and a function can't be sent to the browser.
27
+ // This is the same resource without them, for the client components below.
28
+ const forClient = clientResource(resource);
26
29
  const store = dashboardStore(resource.name);
27
30
  const result = await store.get(id);
28
31
  if (!result.ok) notFound();
@@ -81,7 +84,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
81
84
  ]}
82
85
  actions={
83
86
  <RecordActions
84
- resource={resource}
87
+ resource={forClient}
85
88
  id={id}
86
89
  record={record}
87
90
  relations={relations}
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { PlusIcon } from "lucide-react";
5
- import type { Resource } from "@flaredev/core";
5
+ import type { ClientResource } from "@flaredev/core";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
8
8
  import type { RelationMeta } from "./fields/field-widget";
@@ -11,7 +11,7 @@ import { ResourceForm } from "./resource-form";
11
11
  export type FormRelations = Record<string, RelationMeta & { initialTitle?: string }>;
12
12
 
13
13
  interface Props {
14
- resource: Resource;
14
+ resource: ClientResource;
15
15
  relations: FormRelations;
16
16
  listHref: string;
17
17
  mode: "create" | "edit";
@@ -62,7 +62,7 @@ export function ResourceFormDialog({ resource, relations, listHref, mode, id, re
62
62
  }
63
63
 
64
64
  /** The list page's "New …" button, when forms open in a dialog. */
65
- export function NewRecordButton({ resource, relations, listHref }: { resource: Resource; relations: FormRelations; listHref: string }) {
65
+ export function NewRecordButton({ resource, relations, listHref }: { resource: ClientResource; relations: FormRelations; listHref: string }) {
66
66
  return (
67
67
  <ResourceFormDialog
68
68
  resource={resource}
@@ -1,61 +1,64 @@
1
- import { notFound } from "next/navigation";
2
- import { formatValue, storedFields, type Resource } from "@flaredev/core";
3
- import { resourcePath, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
4
- import type { RelationMeta } from "./fields/field-widget";
5
- import { PageHeader } from "./page-header";
6
- import { ResourceForm } from "./resource-form";
7
-
8
- /** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
9
- export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
10
- await requireAccess(resource, id ? "update" : "create");
11
- let record: Record<string, unknown> | null = null;
12
- if (id) {
13
- const result = await dashboardStore(resource.name).get(id);
14
- if (!result.ok) notFound();
15
- record = result.data;
16
- }
17
-
18
- const byName = new Map(allResources().map((r) => [r.name, r]));
19
- const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
20
- for (const [key, def] of storedFields(resource)) {
21
- if (def.kind !== "belongsTo") continue;
22
- const target = byName.get(def.target);
23
- if (!target) continue;
24
- const value = record?.[key];
25
- const titles = typeof value === "string" ? await dashboardStore(target.name).titles([value]) : {};
26
- relations[key] = {
27
- name: target.name,
28
- label: target.label,
29
- pluralLabel: target.pluralLabel,
30
- slug: target.slug,
31
- titleField: target.titleField,
32
- initialTitle: typeof value === "string" ? titles[value] : undefined,
33
- };
34
- }
35
-
36
- const titleDef = resource.fields[resource.titleField];
37
- const title =
38
- record && titleDef && titleDef.kind !== "hasMany" ? formatValue(titleDef, record[resource.titleField]) : `New ${resource.label.toLowerCase()}`;
39
-
40
- return (
41
- <div className="flex flex-col gap-6">
42
- <PageHeader
43
- title={title}
44
- description={record ? `Edit this ${resource.label.toLowerCase()}.` : `Add a ${resource.label.toLowerCase()} to ${resource.pluralLabel.toLowerCase()}.`}
45
- crumbs={[
46
- { label: "Dashboard", href: "/dashboard" },
47
- { label: resource.pluralLabel, href: resourcePath(resource) },
48
- { label: record ? "Edit" : "New" },
49
- ]}
50
- />
51
- <ResourceForm
52
- resource={resource}
53
- mode={record ? "edit" : "create"}
54
- id={id}
55
- record={record}
56
- relations={relations}
57
- listHref={resourcePath(resource)}
58
- />
59
- </div>
60
- );
61
- }
1
+ import { notFound } from "next/navigation";
2
+ import { clientResource, formatValue, storedFields, type Resource } from "@flaredev/core";
3
+ import { resourcePath, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
4
+ import type { RelationMeta } from "./fields/field-widget";
5
+ import { PageHeader } from "./page-header";
6
+ import { ResourceForm } from "./resource-form";
7
+
8
+ /** Server wrapper for create/edit pages: loads the record and relation titles, renders the heading and form. */
9
+ export async function ResourceFormPage({ resource, id }: { resource: Resource; id?: string }) {
10
+ await requireAccess(resource, id ? "update" : "create");
11
+ // Hooks and computed values are functions, and a function can't be sent to the browser.
12
+ // This is the same resource without them, for the client components below.
13
+ const forClient = clientResource(resource);
14
+ let record: Record<string, unknown> | null = null;
15
+ if (id) {
16
+ const result = await dashboardStore(resource.name).get(id);
17
+ if (!result.ok) notFound();
18
+ record = result.data;
19
+ }
20
+
21
+ const byName = new Map(allResources().map((r) => [r.name, r]));
22
+ const relations: Record<string, RelationMeta & { initialTitle?: string }> = {};
23
+ for (const [key, def] of storedFields(resource)) {
24
+ if (def.kind !== "belongsTo") continue;
25
+ const target = byName.get(def.target);
26
+ if (!target) continue;
27
+ const value = record?.[key];
28
+ const titles = typeof value === "string" ? await dashboardStore(target.name).titles([value]) : {};
29
+ relations[key] = {
30
+ name: target.name,
31
+ label: target.label,
32
+ pluralLabel: target.pluralLabel,
33
+ slug: target.slug,
34
+ titleField: target.titleField,
35
+ initialTitle: typeof value === "string" ? titles[value] : undefined,
36
+ };
37
+ }
38
+
39
+ const titleDef = resource.fields[resource.titleField];
40
+ const title =
41
+ record && titleDef && titleDef.kind !== "hasMany" ? formatValue(titleDef, record[resource.titleField]) : `New ${resource.label.toLowerCase()}`;
42
+
43
+ return (
44
+ <div className="flex flex-col gap-6">
45
+ <PageHeader
46
+ title={title}
47
+ description={record ? `Edit this ${resource.label.toLowerCase()}.` : `Add a ${resource.label.toLowerCase()} to ${resource.pluralLabel.toLowerCase()}.`}
48
+ crumbs={[
49
+ { label: "Dashboard", href: "/dashboard" },
50
+ { label: resource.pluralLabel, href: resourcePath(resource) },
51
+ { label: record ? "Edit" : "New" },
52
+ ]}
53
+ />
54
+ <ResourceForm
55
+ resource={forClient}
56
+ mode={record ? "edit" : "create"}
57
+ id={id}
58
+ record={record}
59
+ relations={relations}
60
+ listHref={resourcePath(resource)}
61
+ />
62
+ </div>
63
+ );
64
+ }