@flaredev/cli 0.5.1 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",
@@ -41,7 +41,7 @@
41
41
  "jiti": "^2.7.0",
42
42
  "jsonc-parser": "^3.3.1",
43
43
  "picocolors": "^1.1.1",
44
- "@flaredev/core": "0.5.1"
44
+ "@flaredev/core": "0.6.1"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { revalidatePath } from "next/cache";
4
4
  import { createObjectKey, fileKeyPrefix, fileMaxBytes, matchesContentType, mimeTypesFor, type FileField } from "@flaredev/core";
5
- import type { FieldIssue } from "@flaredev/core/server";
5
+ import type { FieldIssue } from "@/lib/resource/store";
6
6
  import { diffFields, recordAudit } from "@/lib/audit";
7
7
  import { listViews, MAX_VIEWS } from "@/lib/views";
8
8
  import { and, eq } from "drizzle-orm";
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
7
+
8
+ /**
9
+ * A dashboard page that threw.
10
+ *
11
+ * Inside the layout, so the sidebar stays and `reset` retries just this page — no
12
+ * sign-out, no lost place in the app.
13
+ */
14
+ export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
15
+ useEffect(() => {
16
+ console.error(error);
17
+ }, [error]);
18
+
19
+ return (
20
+ <Empty className="min-h-[60vh]">
21
+ <EmptyHeader>
22
+ <EmptyMedia variant="icon">
23
+ <TriangleAlertIcon />
24
+ </EmptyMedia>
25
+ <EmptyTitle>This page didn&apos;t load</EmptyTitle>
26
+ <EmptyDescription>
27
+ Something went wrong fetching it.
28
+ {error.digest && <> Reference: <span className="font-mono select-all">{error.digest}</span>.</>}
29
+ </EmptyDescription>
30
+ </EmptyHeader>
31
+ <Button onClick={reset}>
32
+ <RefreshCwIcon data-icon="inline-start" />
33
+ Try again
34
+ </Button>
35
+ </Empty>
36
+ );
37
+ }
@@ -3,7 +3,7 @@
3
3
  import { revalidatePath } from "next/cache";
4
4
  import { recordAudit } from "@/lib/audit";
5
5
  import { can, type PolicyAction } from "@flaredev/core";
6
- import type { Failure } from "@flaredev/core/server";
6
+ import type { Failure } from "@/lib/resource/store";
7
7
  import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
8
8
  import type { ActionResult } from "./actions";
9
9
 
@@ -0,0 +1,18 @@
1
+ import { PageHeaderSkeleton, StatsSkeleton, ChartSkeleton, TableSkeleton } from "@/components/dashboard/skeletons";
2
+
3
+ /**
4
+ * The dashboard while its data loads.
5
+ *
6
+ * The sidebar and header are in the layout, so they stay put and only this part is
7
+ * drawn empty — which is the whole benefit of putting the frame in a layout.
8
+ */
9
+ export default function DashboardLoading() {
10
+ return (
11
+ <>
12
+ <PageHeaderSkeleton actions={0} />
13
+ <StatsSkeleton />
14
+ <ChartSkeleton />
15
+ <TableSkeleton rows={5} />
16
+ </>
17
+ );
18
+ }
@@ -0,0 +1,30 @@
1
+ import Link from "next/link";
2
+ import { SearchXIcon } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
5
+
6
+ export const metadata = { title: "Not found" };
7
+
8
+ /**
9
+ * A missing record or page inside the dashboard.
10
+ *
11
+ * Keeps the sidebar and header, because someone who followed a stale link to a
12
+ * deleted record is still signed in and still working — dropping them on the public
13
+ * 404 would throw away that context.
14
+ */
15
+ export default function DashboardNotFound() {
16
+ return (
17
+ <Empty className="min-h-[60vh]">
18
+ <EmptyHeader>
19
+ <EmptyMedia variant="icon">
20
+ <SearchXIcon />
21
+ </EmptyMedia>
22
+ <EmptyTitle>Not found</EmptyTitle>
23
+ <EmptyDescription>This page or record doesn&apos;t exist. It may have been deleted.</EmptyDescription>
24
+ </EmptyHeader>
25
+ <Button asChild>
26
+ <Link href="/dashboard">Back to the dashboard</Link>
27
+ </Button>
28
+ </Empty>
29
+ );
30
+ }
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import Link from "next/link";
5
+ import { RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
6
+
7
+ /**
8
+ * The public error boundary.
9
+ *
10
+ * Reached when a page throws. It is a client component because it has to be — React
11
+ * needs somewhere to hand `reset`, which re-renders the segment without a full page
12
+ * load, and that is usually all a transient failure needs.
13
+ *
14
+ * `error.digest` is the only detail shown. The message itself is deliberately not:
15
+ * in production it is replaced by React anyway, and in development the overlay is
16
+ * more use than anything printed here.
17
+ */
18
+ export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
19
+ useEffect(() => {
20
+ // The browser console in development; whatever collects console output in production.
21
+ console.error(error);
22
+ }, [error]);
23
+
24
+ return (
25
+ <main className="mx-auto flex min-h-[70vh] max-w-2xl flex-col items-center justify-center gap-6 px-6 py-20 text-center">
26
+ <div className="flex size-14 items-center justify-center rounded-2xl border border-border bg-surface">
27
+ <TriangleAlertIcon className="size-6 text-danger" aria-hidden="true" />
28
+ </div>
29
+ <div className="flex flex-col gap-3">
30
+ <h1 className="text-3xl text-balance md:text-4xl">Something went wrong</h1>
31
+ <p className="text-foreground-muted text-pretty">
32
+ This one is on us. Trying again often works; if it doesn&apos;t, the error has been logged.
33
+ </p>
34
+ {error.digest && (
35
+ <p className="font-mono text-xs text-foreground-muted">
36
+ Reference: <span className="select-all">{error.digest}</span>
37
+ </p>
38
+ )}
39
+ </div>
40
+ <div className="flex flex-wrap justify-center gap-3">
41
+ <button
42
+ type="button"
43
+ onClick={reset}
44
+ className="inline-flex h-11 items-center gap-2 rounded-[var(--button-radius)] bg-brand px-5 font-medium text-brand-foreground [background-image:var(--brand-gradient,none)] hover:brightness-95"
45
+ >
46
+ <RefreshCwIcon className="size-4" aria-hidden="true" />
47
+ Try again
48
+ </button>
49
+ <Link href="/" className="inline-flex h-11 items-center rounded-[var(--button-radius)] border border-border px-5 font-medium hover:bg-accent">
50
+ Back home
51
+ </Link>
52
+ </div>
53
+ </main>
54
+ );
55
+ }
@@ -0,0 +1,22 @@
1
+ import { Skeleton } from "@/components/ui/skeleton";
2
+
3
+ /**
4
+ * The public shell while a page loads.
5
+ *
6
+ * Deliberately vague — it stands in for any public page, and guessing a layout it
7
+ * can't know would move things around once the real one arrives. Pages with a shape
8
+ * worth matching have their own `loading.tsx` beside them.
9
+ */
10
+ export default function Loading() {
11
+ return (
12
+ <main className="mx-auto flex max-w-4xl flex-col items-center gap-6 px-6 py-24">
13
+ <Skeleton className="h-10 w-3/4 max-w-xl" />
14
+ <Skeleton className="h-10 w-2/3 max-w-lg" />
15
+ <Skeleton className="mt-2 h-5 w-full max-w-md" />
16
+ <div className="mt-6 flex gap-3">
17
+ <Skeleton className="h-12 w-36 rounded-[var(--button-radius)]" />
18
+ <Skeleton className="h-12 w-32 rounded-[var(--button-radius)]" />
19
+ </div>
20
+ </main>
21
+ );
22
+ }
@@ -0,0 +1,48 @@
1
+ import Link from "next/link";
2
+ import { ArrowLeftIcon, SearchXIcon } from "lucide-react";
3
+ import { SiteHeader } from "@/components/marketing/site-header";
4
+
5
+ export const metadata = { title: "Page not found" };
6
+
7
+ /**
8
+ * The public 404.
9
+ *
10
+ * Shown for any address that doesn't match a route, so it keeps the site's chrome:
11
+ * someone who mistyped a URL should still be able to get where they were going,
12
+ * which is why the two ways out are a link home and a link to sign in rather than
13
+ * an apology.
14
+ */
15
+ export default function NotFound() {
16
+ return (
17
+ <>
18
+ <SiteHeader />
19
+ <main className="mx-auto flex min-h-[70vh] max-w-2xl flex-col items-center justify-center gap-6 px-6 py-20 text-center">
20
+ <div className="flex size-14 items-center justify-center rounded-2xl border border-border bg-surface">
21
+ <SearchXIcon className="size-6 text-foreground-muted" aria-hidden="true" />
22
+ </div>
23
+ <div className="flex flex-col gap-3">
24
+ <p className="font-mono text-sm tracking-widest text-foreground-muted">404</p>
25
+ <h1 className="text-3xl text-balance md:text-4xl">This page doesn&apos;t exist</h1>
26
+ <p className="text-foreground-muted text-pretty">
27
+ The link may be out of date, or the address may have a typo in it.
28
+ </p>
29
+ </div>
30
+ <div className="flex flex-wrap justify-center gap-3">
31
+ <Link
32
+ href="/"
33
+ className="inline-flex h-11 items-center gap-2 rounded-[var(--button-radius)] bg-brand px-5 font-medium text-brand-foreground [background-image:var(--brand-gradient,none)] hover:brightness-95"
34
+ >
35
+ <ArrowLeftIcon className="size-4" aria-hidden="true" />
36
+ Back home
37
+ </Link>
38
+ <Link
39
+ href="/dashboard"
40
+ className="inline-flex h-11 items-center rounded-[var(--button-radius)] border border-border px-5 font-medium hover:bg-accent"
41
+ >
42
+ Go to the dashboard
43
+ </Link>
44
+ </div>
45
+ </main>
46
+ </>
47
+ );
48
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { FileIcon } from "lucide-react";
5
+ import { createReadUrlAction } from "@/app/dashboard/actions";
6
+
7
+ /** Keys that are worth showing rather than naming. */
8
+ const isImageKey = (key: string) => /\.(png|jpe?g|gif|webp|avif)$/i.test(key);
9
+
10
+ /** "products/2026/09/kettle-a1b2c3.png" → "kettle-a1b2c3.png" */
11
+ const basename = (key: string) => key.slice(key.lastIndexOf("/") + 1);
12
+
13
+ /**
14
+ * A stored file in a table cell or a record's detail.
15
+ *
16
+ * Objects in the bucket are private, so there is no URL to put in `src` — one has to
17
+ * be signed, which is a server action, which makes this a client component. The same
18
+ * round trip the form field does, for the same reason.
19
+ *
20
+ * An image becomes a thumbnail; anything else is its filename, which is the useful
21
+ * part of a key nobody wants to read in full.
22
+ */
23
+ export function FileCell({
24
+ resourceName,
25
+ fieldKey,
26
+ value,
27
+ size = 36,
28
+ }: {
29
+ resourceName: string;
30
+ fieldKey: string;
31
+ value: string;
32
+ size?: number;
33
+ }) {
34
+ const [url, setUrl] = useState<string | null>(null);
35
+ const image = isImageKey(value);
36
+
37
+ useEffect(() => {
38
+ if (!image) return;
39
+ let cancelled = false;
40
+ void createReadUrlAction(resourceName, fieldKey, value).then((result) => {
41
+ if (!cancelled && result.ok) setUrl(result.data.url);
42
+ });
43
+ return () => {
44
+ cancelled = true;
45
+ };
46
+ }, [image, value, resourceName, fieldKey]);
47
+
48
+ if (!image) {
49
+ return (
50
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
51
+ <FileIcon aria-hidden="true" className="size-3.5" />
52
+ <span className="max-w-[16ch] truncate">{basename(value)}</span>
53
+ </span>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <span
59
+ className="inline-flex items-center justify-center overflow-hidden rounded-md border bg-muted align-middle"
60
+ style={{ width: size, height: size }}
61
+ >
62
+ {url ? (
63
+ // eslint-disable-next-line @next/next/no-img-element -- signed, short-lived URL
64
+ <img src={url} alt="" width={size} height={size} className="size-full object-cover" />
65
+ ) : null}
66
+ </span>
67
+ );
68
+ }
@@ -2,6 +2,7 @@ import Link from "next/link";
2
2
  import { notFound } from "next/navigation";
3
3
  import { clientResource, formatValue, optionLabel, relationGraph, statusTone, storedFields, type Resource } from "@flaredev/core";
4
4
  import { Badge } from "@/components/ui/badge";
5
+ import { FileCell } from "@/components/dashboard/fields/file-cell";
5
6
  import { Button } from "@/components/ui/button";
6
7
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7
8
  import { recentAudit } from "@/lib/audit";
@@ -111,6 +112,9 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
111
112
  } else if (def.kind === "enum" && value != null) {
112
113
  const tone = statusTone(value);
113
114
  content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
115
+ } else if (def.kind === "file" && typeof value === "string" && value) {
116
+ // Bigger here than in the table: this is the page for looking at one record.
117
+ content = <FileCell resourceName={resource.name} fieldKey={key} value={value} size={96} />;
114
118
  } else if (def.kind === "multiselect" && Array.isArray(value) && value.length > 0) {
115
119
  content = (
116
120
  <span className="flex flex-wrap gap-1">
@@ -1,7 +1,8 @@
1
1
  import Link from "next/link";
2
+ import { FileCell } from "@/components/dashboard/fields/file-cell";
2
3
  import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
3
4
  import { clientResource, formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
4
- import { isSortable } from "@flaredev/core/server";
5
+ import { isSortable } from "@/lib/resource/query";
5
6
  import { Alert, AlertDescription } from "@/components/ui/alert";
6
7
  import { Badge } from "@/components/ui/badge";
7
8
  import { Button } from "@/components/ui/button";
@@ -207,6 +208,10 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
207
208
  } else if (column.def.kind === "enum" && value != null) {
208
209
  const tone = statusTone(value);
209
210
  content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
211
+ } else if (column.def.kind === "file" && typeof value === "string" && value) {
212
+ // A thumbnail for an image, a filename for anything else — the raw
213
+ // object key is noise in a table.
214
+ content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} />;
210
215
  } else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
211
216
  const def = column.def;
212
217
  content = (
@@ -0,0 +1,146 @@
1
+ import { Skeleton } from "@/components/ui/skeleton";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ /**
5
+ * The shapes pages take while their data is on its way.
6
+ *
7
+ * Each one is the layout it stands in for, drawn empty — the same header, the same
8
+ * number of columns, cards the same size. That is the whole point: a skeleton that
9
+ * matches keeps the page still when the content lands, where a spinner throws
10
+ * everything into place at once and makes the wait feel like a jump.
11
+ *
12
+ * They are built from the same tokens as the real thing, so they follow the theme
13
+ * without knowing which theme it is.
14
+ */
15
+
16
+ /** One line of text. `w` is a Tailwind width, so a heading and a caption differ. */
17
+ function Line({ className }: { className?: string }) {
18
+ return <Skeleton className={cn("h-4 w-40", className)} />;
19
+ }
20
+
21
+ /** Title, description and the buttons on the right — matches PageHeader. */
22
+ export function PageHeaderSkeleton({ actions = 1 }: { actions?: number }) {
23
+ return (
24
+ <div className="flex flex-col gap-3">
25
+ <div className="flex items-center gap-2">
26
+ <Line className="h-3 w-20" />
27
+ <Line className="h-3 w-24" />
28
+ </div>
29
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
30
+ <div className="flex flex-col gap-2">
31
+ <Skeleton className="h-7 w-48" />
32
+ <Line className="w-64" />
33
+ </div>
34
+ {actions > 0 && (
35
+ <div className="flex gap-2">
36
+ {Array.from({ length: actions }, (_, index) => (
37
+ <Skeleton key={index} className="h-9 w-28 rounded-[var(--button-radius)]" />
38
+ ))}
39
+ </div>
40
+ )}
41
+ </div>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ /** The row of counts above a resource table. */
47
+ export function StatsSkeleton({ cards = 4 }: { cards?: number }) {
48
+ return (
49
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
50
+ {Array.from({ length: cards }, (_, index) => (
51
+ <div key={index} className="rounded-xl border border-border bg-card p-4">
52
+ <Line className="h-3 w-24" />
53
+ <Skeleton className="mt-3 h-7 w-16" />
54
+ <Line className="mt-2 h-3 w-20" />
55
+ </div>
56
+ ))}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /** A chart card. The bars vary so it reads as a chart rather than a block. */
62
+ export function ChartSkeleton({ className }: { className?: string }) {
63
+ const bars = [45, 70, 35, 85, 55, 75, 40, 65, 50, 80, 60, 45];
64
+ return (
65
+ <div className={cn("rounded-xl border border-border bg-card p-4", className)}>
66
+ <Line className="h-3 w-32" />
67
+ <div className="mt-4 flex h-32 items-end gap-2" aria-hidden="true">
68
+ {bars.map((height, index) => (
69
+ <Skeleton key={index} className="flex-1 rounded-sm" style={{ height: `${height}%` }} />
70
+ ))}
71
+ </div>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ /** The toolbar and rows of a resource table. */
77
+ export function TableSkeleton({ rows = 8, columns = 5 }: { rows?: number; columns?: number }) {
78
+ return (
79
+ <div className="flex flex-col gap-4">
80
+ <div className="flex flex-wrap items-center gap-2">
81
+ <Skeleton className="h-9 w-full max-w-xs rounded-[var(--button-radius)]" />
82
+ <Skeleton className="h-9 w-24 rounded-[var(--button-radius)]" />
83
+ <Skeleton className="h-9 w-24 rounded-[var(--button-radius)]" />
84
+ <Skeleton className="ml-auto h-9 w-20 rounded-[var(--button-radius)]" />
85
+ </div>
86
+ <div className="overflow-hidden rounded-xl border border-border">
87
+ <div className="flex items-center gap-4 border-b border-border bg-muted/40 px-4 py-3">
88
+ {Array.from({ length: columns }, (_, index) => (
89
+ <Line key={index} className={cn("h-3", index === 0 ? "w-32" : "w-20")} />
90
+ ))}
91
+ </div>
92
+ {Array.from({ length: rows }, (_, row) => (
93
+ <div key={row} className="flex items-center gap-4 border-b border-border px-4 py-3 last:border-b-0">
94
+ {Array.from({ length: columns }, (_, column) => (
95
+ <Line key={column} className={cn("h-4", column === 0 ? "w-32" : "w-20")} />
96
+ ))}
97
+ </div>
98
+ ))}
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ /** A form: fields in a column, buttons at the end. */
105
+ export function FormSkeleton({ fields = 5 }: { fields?: number }) {
106
+ return (
107
+ <div className="flex max-w-2xl flex-col gap-6">
108
+ {Array.from({ length: fields }, (_, index) => (
109
+ <div key={index} className="flex flex-col gap-2">
110
+ <Line className="h-3 w-24" />
111
+ <Skeleton className="h-9 w-full rounded-[var(--button-radius)]" />
112
+ </div>
113
+ ))}
114
+ <div className="flex gap-2">
115
+ <Skeleton className="h-9 w-24 rounded-[var(--button-radius)]" />
116
+ <Skeleton className="h-9 w-20 rounded-[var(--button-radius)]" />
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ /** One record: its fields as label/value pairs. */
123
+ export function DetailSkeleton({ rows = 6 }: { rows?: number }) {
124
+ return (
125
+ <div className="rounded-xl border border-border bg-card divide-y divide-border">
126
+ {Array.from({ length: rows }, (_, index) => (
127
+ <div key={index} className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-8">
128
+ <Line className="h-3 w-28 shrink-0" />
129
+ <Line className="h-4 w-48" />
130
+ </div>
131
+ ))}
132
+ </div>
133
+ );
134
+ }
135
+
136
+ /** The whole of a resource's list page, for its `loading.tsx`. */
137
+ export function ResourceListSkeleton({ columns = 5 }: { columns?: number }) {
138
+ return (
139
+ <>
140
+ <PageHeaderSkeleton />
141
+ <StatsSkeleton />
142
+ <ChartSkeleton />
143
+ <TableSkeleton columns={columns} />
144
+ </>
145
+ );
146
+ }
@@ -1,6 +1,6 @@
1
1
  import { headers } from "next/headers";
2
2
  import { can, type Policy } from "@flaredev/core";
3
- import type { Authorize } from "@flaredev/core/server";
3
+ import type { Authorize } from "@/lib/resource/handlers";
4
4
  import { policies } from "@/policies";
5
5
  import { auth } from "./auth";
6
6