@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/README.md +59 -47
- package/dist/index.js +368 -146
- package/package.json +2 -2
- package/templates/app/app/dashboard/actions.ts +1 -1
- package/templates/app/app/dashboard/error.tsx +37 -0
- package/templates/app/app/dashboard/import-actions.ts +1 -1
- package/templates/app/app/dashboard/loading.tsx +18 -0
- package/templates/app/app/dashboard/not-found.tsx +30 -0
- package/templates/app/app/error.tsx +55 -0
- package/templates/app/app/loading.tsx +22 -0
- package/templates/app/app/not-found.tsx +48 -0
- package/templates/app/components/dashboard/fields/file-cell.tsx +68 -0
- package/templates/app/components/dashboard/record-detail.tsx +4 -0
- package/templates/app/components/dashboard/resource-table.tsx +6 -1
- package/templates/app/components/dashboard/skeletons.tsx +146 -0
- package/templates/app/lib/api.ts +1 -1
- package/templates/app/lib/cache.ts +83 -83
- package/templates/app/lib/dashboard.ts +2 -2
- package/templates/app/lib/resource/drizzle-rows.ts +134 -0
- package/templates/app/lib/resource/handlers.ts +157 -0
- package/templates/app/lib/resource/index.ts +14 -0
- package/templates/app/lib/resource/query.ts +149 -0
- package/templates/app/lib/resource/rows.ts +66 -0
- package/templates/app/lib/resource/store.ts +342 -0
- package/templates/next/app/dashboard/actions.ts +1 -1
- package/templates/next/lib/dashboard.ts +1 -1
- package/templates/next/lib/resource/index.ts +14 -0
- package/templates/next/lib/resource/prisma-rows.ts +169 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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 "
|
|
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'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 "
|
|
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'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'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'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 "
|
|
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
|
+
}
|
package/templates/app/lib/api.ts
CHANGED
|
@@ -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 "
|
|
3
|
+
import type { Authorize } from "@/lib/resource/handlers";
|
|
4
4
|
import { policies } from "@/policies";
|
|
5
5
|
import { auth } from "./auth";
|
|
6
6
|
|