@flaredev/cli 0.3.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.
- package/dist/index.js +498 -220
- package/package.json +2 -2
- package/templates/app/app/dashboard/account/layout.tsx +30 -0
- package/templates/app/app/dashboard/account/page.tsx +11 -33
- package/templates/app/app/dashboard/account/password/page.tsx +22 -0
- package/templates/app/app/dashboard/account/security/page.tsx +34 -0
- package/templates/app/app/dashboard/account/sessions/page.tsx +9 -0
- package/templates/app/app/dashboard/actions.ts +73 -5
- package/templates/app/app/dashboard/layout.tsx +28 -4
- package/templates/app/app/dashboard/observability/page.tsx +4 -3
- package/templates/app/app/dashboard/page.tsx +2 -2
- package/templates/app/app/globals.css +0 -6
- package/templates/app/app/layout.tsx +37 -28
- package/templates/app/components/account/account-nav.tsx +39 -0
- package/templates/app/components/account/{security-settings.tsx → sections.tsx} +507 -522
- package/templates/app/components/dashboard/column-menu.tsx +66 -0
- package/templates/app/components/dashboard/dashboard-header.tsx +115 -78
- package/templates/app/components/dashboard/dashboard-sidebar.tsx +86 -24
- package/templates/app/components/dashboard/dropzone.tsx +92 -0
- package/templates/app/components/dashboard/editable.ts +12 -0
- package/templates/app/components/dashboard/fields/file-field.tsx +310 -302
- package/templates/app/components/dashboard/fields/relation-field.tsx +25 -3
- package/templates/app/components/dashboard/import-dialog.tsx +62 -9
- package/templates/app/components/dashboard/inline-cell.tsx +165 -0
- package/templates/app/components/dashboard/local-time.tsx +77 -0
- package/templates/app/components/dashboard/query.ts +2 -0
- package/templates/app/components/dashboard/record-actions.tsx +113 -0
- package/templates/app/components/dashboard/record-detail.tsx +203 -0
- package/templates/app/components/dashboard/resource-chart.tsx +91 -0
- package/templates/app/components/dashboard/resource-form-dialog.tsx +3 -3
- package/templates/app/components/dashboard/resource-form-page.tsx +64 -61
- package/templates/app/components/dashboard/resource-form.tsx +165 -165
- package/templates/app/components/dashboard/resource-stats.tsx +6 -11
- package/templates/app/components/dashboard/resource-table-toolbar.tsx +2 -2
- package/templates/app/components/dashboard/resource-table.tsx +408 -331
- package/templates/app/components/dashboard/row-actions.tsx +156 -144
- package/templates/app/components/dashboard/save-view-button.tsx +86 -0
- package/templates/app/components/dashboard/user-menu.tsx +91 -0
- package/templates/app/components/ui/sonner.tsx +33 -13
- package/templates/app/db/flare-schema.ts +22 -0
- package/templates/app/lib/api.ts +12 -0
- package/templates/app/lib/dashboard.ts +165 -138
- package/templates/app/lib/views.ts +29 -0
- package/templates/app/migrations/0000_init.sql +11 -0
- package/templates/app/migrations/meta/0000_snapshot.json +63 -1
- package/templates/app/vite.config.ts +41 -31
- package/templates/locks/package-lock.json +9 -9
- package/templates/locks/pnpm-lock.yaml +12 -12
- package/templates/locks/yarn.lock +3548 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
13
|
+
type ClientResource,
|
|
14
14
|
type StoredField,
|
|
15
15
|
} from "@flaredev/core";
|
|
16
16
|
import { importRecordsAction } from "@/app/dashboard/import-actions";
|
|
@@ -26,11 +26,48 @@ import {
|
|
|
26
26
|
DialogTrigger,
|
|
27
27
|
} from "@/components/ui/dialog";
|
|
28
28
|
import { Field, FieldLabel } from "@/components/ui/field";
|
|
29
|
-
import { Input } from "@/components/ui/input";
|
|
30
29
|
import { Progress } from "@/components/ui/progress";
|
|
31
30
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
32
31
|
import { Spinner } from "@/components/ui/spinner";
|
|
33
32
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
33
|
+
import { toCsv } from "@/lib/csv";
|
|
34
|
+
import { Dropzone } from "./dropzone";
|
|
35
|
+
import { downloadCsv } from "./table-selection";
|
|
36
|
+
|
|
37
|
+
/** What a column looks like in the downloadable template. */
|
|
38
|
+
function exampleFor(key: string, def: StoredField & { label: string }): string {
|
|
39
|
+
switch (def.kind) {
|
|
40
|
+
case "string":
|
|
41
|
+
if (def.format === "email") return "ada@example.com";
|
|
42
|
+
if (def.format === "tel") return "+256772123456";
|
|
43
|
+
if (def.format === "url") return "https://example.com";
|
|
44
|
+
if (def.format === "domain") return "example.com";
|
|
45
|
+
if (def.format === "country") return "UG";
|
|
46
|
+
if (def.format === "color") return "#f2541d";
|
|
47
|
+
if (def.format === "slug") return "my-first-" + key.toLowerCase();
|
|
48
|
+
return def.label;
|
|
49
|
+
case "text":
|
|
50
|
+
return "A longer note about this record.";
|
|
51
|
+
case "int":
|
|
52
|
+
return "42";
|
|
53
|
+
case "float":
|
|
54
|
+
return "9.99";
|
|
55
|
+
case "boolean":
|
|
56
|
+
return "true";
|
|
57
|
+
case "date":
|
|
58
|
+
return new Date().toISOString().slice(0, 10);
|
|
59
|
+
case "datetime":
|
|
60
|
+
return new Date().toISOString();
|
|
61
|
+
case "enum":
|
|
62
|
+
return String((def as EnumField).options[0] ?? "");
|
|
63
|
+
case "multiselect":
|
|
64
|
+
return ((def as MultiSelectField).options.slice(0, 2) as string[]).join("; ");
|
|
65
|
+
case "belongsTo":
|
|
66
|
+
return "id of an existing " + (def as { target: string }).target;
|
|
67
|
+
default:
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
34
71
|
|
|
35
72
|
/** Sentinel for "don't import this field": a SelectItem can't carry an empty value. */
|
|
36
73
|
const SKIP = "__skip__";
|
|
@@ -165,7 +202,7 @@ function guessMapping(fields: MappedField[], headers: string[]): Record<string,
|
|
|
165
202
|
* schemas and again on the server, and sent in batches so a long file reports progress
|
|
166
203
|
* and never depends on one very long request.
|
|
167
204
|
*/
|
|
168
|
-
export function ImportDialog({ resource }: { resource:
|
|
205
|
+
export function ImportDialog({ resource }: { resource: ClientResource }) {
|
|
169
206
|
const router = useRouter();
|
|
170
207
|
const validators = useMemo(() => createValidators(resource), [resource]);
|
|
171
208
|
// Uploads need a signed URL per file, which a CSV cell can't stand in for.
|
|
@@ -186,6 +223,13 @@ export function ImportDialog({ resource }: { resource: Resource }) {
|
|
|
186
223
|
const unmapped = fields.filter(([key, def]) => needsColumn(def) && (mapping[key] === undefined || mapping[key] === SKIP));
|
|
187
224
|
const ready = rows.length > 0 && mapped.length > 0 && unmapped.length === 0;
|
|
188
225
|
|
|
226
|
+
/** A starter file: the header this import wants, and one row of what each column holds. */
|
|
227
|
+
function downloadTemplate() {
|
|
228
|
+
const columns = fields.map(([key, def]) => ({ key, label: def.label }));
|
|
229
|
+
const example = Object.fromEntries(fields.map(([key, def]) => [key, exampleFor(key, def)]));
|
|
230
|
+
downloadCsv(`${resource.slug}-template.csv`, toCsv([example], columns));
|
|
231
|
+
}
|
|
232
|
+
|
|
189
233
|
function reset() {
|
|
190
234
|
setFileName("");
|
|
191
235
|
setHeaders([]);
|
|
@@ -197,8 +241,8 @@ export function ImportDialog({ resource }: { resource: Resource }) {
|
|
|
197
241
|
setSummary(null);
|
|
198
242
|
}
|
|
199
243
|
|
|
200
|
-
async function onFile(
|
|
201
|
-
const file =
|
|
244
|
+
async function onFile(files: File[]) {
|
|
245
|
+
const file = files[0];
|
|
202
246
|
if (!file) return;
|
|
203
247
|
reset();
|
|
204
248
|
|
|
@@ -319,10 +363,19 @@ export function ImportDialog({ resource }: { resource: Resource }) {
|
|
|
319
363
|
</DialogHeader>
|
|
320
364
|
|
|
321
365
|
<div className="grid gap-6">
|
|
322
|
-
<
|
|
323
|
-
<
|
|
324
|
-
|
|
325
|
-
|
|
366
|
+
<div className="flex flex-col gap-2">
|
|
367
|
+
<Dropzone
|
|
368
|
+
onFiles={onFile}
|
|
369
|
+
accept=".csv,text/csv"
|
|
370
|
+
disabled={running}
|
|
371
|
+
label={fileName ? fileName : "Drop a CSV here"}
|
|
372
|
+
hint={fileName ? `${rows.length.toLocaleString()} rows ready to map` : "One row per " + resource.label.toLowerCase() + ", with a header row on top"}
|
|
373
|
+
/>
|
|
374
|
+
<Button type="button" variant="link" size="sm" className="h-auto w-fit p-0" onClick={downloadTemplate}>
|
|
375
|
+
<DownloadIcon data-icon="inline-start" />
|
|
376
|
+
Download a template with an example row
|
|
377
|
+
</Button>
|
|
378
|
+
</div>
|
|
326
379
|
|
|
327
380
|
{fileError && (
|
|
328
381
|
<Alert variant="destructive">
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useTransition } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { CheckIcon, ChevronDownIcon, PencilIcon, XIcon } from "lucide-react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
import { optionLabel, type StoredField } from "@flaredev/core";
|
|
8
|
+
import { updateRecordAction } from "@/app/dashboard/actions";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
12
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
13
|
+
import { Switch } from "@/components/ui/switch";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
resourceName: string;
|
|
18
|
+
id: string;
|
|
19
|
+
fieldKey: string;
|
|
20
|
+
def: StoredField & { label: string };
|
|
21
|
+
value: unknown;
|
|
22
|
+
/** What the cell shows when it isn't being edited. */
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NONE = "__none__";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Edit one value without leaving the table.
|
|
30
|
+
*
|
|
31
|
+
* For the small changes — a status, a flag, a number — opening a form to change one
|
|
32
|
+
* field and closing it again is most of the work. Anything with a picker, an upload or
|
|
33
|
+
* a relation still goes through the form, where there's room to explain itself.
|
|
34
|
+
*/
|
|
35
|
+
export function InlineCell({ resourceName, id, fieldKey, def, value, children }: Props) {
|
|
36
|
+
const router = useRouter();
|
|
37
|
+
const [editing, setEditing] = useState(false);
|
|
38
|
+
const [draft, setDraft] = useState("");
|
|
39
|
+
const [pending, startTransition] = useTransition();
|
|
40
|
+
const input = useRef<HTMLInputElement>(null);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (editing) input.current?.focus();
|
|
44
|
+
}, [editing]);
|
|
45
|
+
|
|
46
|
+
const save = (next: unknown) => {
|
|
47
|
+
setEditing(false);
|
|
48
|
+
if (next === value || (next === null && value === null)) return;
|
|
49
|
+
startTransition(async () => {
|
|
50
|
+
const result = await updateRecordAction(resourceName, id, { [fieldKey]: next });
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
toast.error(result.issues?.[0]?.message ?? result.error);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
toast.success(`${def.label} saved.`);
|
|
56
|
+
router.refresh();
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (pending) {
|
|
61
|
+
return (
|
|
62
|
+
<span className="inline-flex items-center gap-2 text-muted-foreground">
|
|
63
|
+
<Spinner className="size-3.5" />
|
|
64
|
+
{children}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A switch and a select are one click either way, so they're always live.
|
|
70
|
+
if (def.kind === "boolean") {
|
|
71
|
+
return (
|
|
72
|
+
<Switch
|
|
73
|
+
checked={value === true}
|
|
74
|
+
onCheckedChange={(checked) => save(checked)}
|
|
75
|
+
aria-label={`${def.label} for this record`}
|
|
76
|
+
className="data-[state=unchecked]:bg-muted-foreground/30"
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (def.kind === "enum") {
|
|
82
|
+
// The badge stays until it's clicked: a row of grey dropdowns loses the colour that
|
|
83
|
+
// makes a status column readable at a glance.
|
|
84
|
+
if (!editing) {
|
|
85
|
+
return (
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setEditing(true)}
|
|
89
|
+
className="group/cell inline-flex items-center gap-1 rounded px-0.5 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
|
|
90
|
+
aria-label={`Change ${def.label.toLowerCase()}`}
|
|
91
|
+
>
|
|
92
|
+
{children}
|
|
93
|
+
<ChevronDownIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/cell:opacity-100" aria-hidden="true" />
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
<Select
|
|
99
|
+
defaultOpen
|
|
100
|
+
value={typeof value === "string" ? value : NONE}
|
|
101
|
+
onValueChange={(next) => save(next === NONE ? null : next)}
|
|
102
|
+
onOpenChange={(open) => {
|
|
103
|
+
if (!open) setEditing(false);
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
<SelectTrigger size="sm" className="h-7 w-auto px-1.5" aria-label={def.label}>
|
|
107
|
+
<SelectValue placeholder="—" />
|
|
108
|
+
</SelectTrigger>
|
|
109
|
+
<SelectContent>
|
|
110
|
+
<SelectGroup>
|
|
111
|
+
{!def.required && <SelectItem value={NONE}>—</SelectItem>}
|
|
112
|
+
{def.options.map((option) => (
|
|
113
|
+
<SelectItem key={option} value={option}>
|
|
114
|
+
{optionLabel(def, option)}
|
|
115
|
+
</SelectItem>
|
|
116
|
+
))}
|
|
117
|
+
</SelectGroup>
|
|
118
|
+
</SelectContent>
|
|
119
|
+
</Select>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (editing) {
|
|
124
|
+
const numeric = def.kind === "int" || def.kind === "float";
|
|
125
|
+
return (
|
|
126
|
+
<span className="flex items-center gap-1">
|
|
127
|
+
<Input
|
|
128
|
+
ref={input}
|
|
129
|
+
value={draft}
|
|
130
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
131
|
+
type={numeric ? "number" : "text"}
|
|
132
|
+
step={def.kind === "float" ? "any" : undefined}
|
|
133
|
+
className="h-7 w-full min-w-24 px-1.5"
|
|
134
|
+
aria-label={def.label}
|
|
135
|
+
onKeyDown={(event) => {
|
|
136
|
+
if (event.key === "Enter") save(numeric ? (draft === "" ? null : Number(draft)) : draft);
|
|
137
|
+
if (event.key === "Escape") setEditing(false);
|
|
138
|
+
}}
|
|
139
|
+
onBlur={() => save(numeric ? (draft === "" ? null : Number(draft)) : draft)}
|
|
140
|
+
/>
|
|
141
|
+
<Button variant="ghost" size="icon" className="size-6" aria-label="Save" onMouseDown={(event) => event.preventDefault()}>
|
|
142
|
+
<CheckIcon />
|
|
143
|
+
</Button>
|
|
144
|
+
<Button variant="ghost" size="icon" className="size-6" aria-label="Cancel" onMouseDown={(event) => { event.preventDefault(); setEditing(false); }}>
|
|
145
|
+
<XIcon />
|
|
146
|
+
</Button>
|
|
147
|
+
</span>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
onClick={() => {
|
|
155
|
+
setDraft(value === null || value === undefined ? "" : String(value));
|
|
156
|
+
setEditing(true);
|
|
157
|
+
}}
|
|
158
|
+
className={cn("group/cell inline-flex max-w-full items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-muted", "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary")}
|
|
159
|
+
aria-label={`Edit ${def.label.toLowerCase()}`}
|
|
160
|
+
>
|
|
161
|
+
<span className="truncate">{children}</span>
|
|
162
|
+
<PencilIcon className="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/cell:opacity-100" aria-hidden="true" />
|
|
163
|
+
</button>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A timestamp in the reader's own timezone.
|
|
7
|
+
*
|
|
8
|
+
* Pages render on a Worker, where the clock is UTC, so a date formatted on the server is
|
|
9
|
+
* in the wrong timezone for everyone. The server's version is rendered first (so the
|
|
10
|
+
* markup isn't empty and search engines see something), then the browser replaces it
|
|
11
|
+
* with its own once it's running.
|
|
12
|
+
*/
|
|
13
|
+
export function LocalTime({
|
|
14
|
+
value,
|
|
15
|
+
dateStyle = "medium",
|
|
16
|
+
timeStyle = "short",
|
|
17
|
+
className,
|
|
18
|
+
}: {
|
|
19
|
+
/** Milliseconds, an ISO string, or a Date. */
|
|
20
|
+
value: number | string | Date | null | undefined;
|
|
21
|
+
dateStyle?: Intl.DateTimeFormatOptions["dateStyle"];
|
|
22
|
+
timeStyle?: Intl.DateTimeFormatOptions["timeStyle"];
|
|
23
|
+
className?: string;
|
|
24
|
+
}) {
|
|
25
|
+
const date = value === null || value === undefined ? null : value instanceof Date ? value : new Date(value);
|
|
26
|
+
const utc = date ? new Intl.DateTimeFormat("en-GB", { dateStyle, timeStyle, timeZone: "UTC" }).format(date) : "—";
|
|
27
|
+
const [text, setText] = useState(utc);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!date) return;
|
|
31
|
+
setText(new Intl.DateTimeFormat(undefined, { dateStyle, timeStyle }).format(date));
|
|
32
|
+
}, [date?.getTime(), dateStyle, timeStyle]);
|
|
33
|
+
|
|
34
|
+
if (!date) return <span className={className}>—</span>;
|
|
35
|
+
return (
|
|
36
|
+
<time dateTime={date.toISOString()} title={date.toISOString()} className={className} suppressHydrationWarning>
|
|
37
|
+
{text}
|
|
38
|
+
</time>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** "3 minutes ago", "yesterday", falling back to a date once it's a week old. */
|
|
43
|
+
export function RelativeTime({ value, className }: { value: number | string | Date | null | undefined; className?: string }) {
|
|
44
|
+
const date = value === null || value === undefined ? null : value instanceof Date ? value : new Date(value);
|
|
45
|
+
const [text, setText] = useState<string | null>(null);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!date) return;
|
|
49
|
+
const format = () => {
|
|
50
|
+
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
|
|
51
|
+
const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
|
|
52
|
+
const units: [Intl.RelativeTimeFormatUnit, number][] = [
|
|
53
|
+
["second", 60],
|
|
54
|
+
["minute", 60],
|
|
55
|
+
["hour", 24],
|
|
56
|
+
["day", 7],
|
|
57
|
+
];
|
|
58
|
+
let value = seconds;
|
|
59
|
+
for (const [unit, size] of units) {
|
|
60
|
+
if (Math.abs(value) < size) return relative.format(Math.round(value), unit);
|
|
61
|
+
value /= size;
|
|
62
|
+
}
|
|
63
|
+
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date);
|
|
64
|
+
};
|
|
65
|
+
setText(format());
|
|
66
|
+
// Re-read every half minute, so "just now" doesn't stay "just now" all afternoon.
|
|
67
|
+
const timer = setInterval(() => setText(format()), 30_000);
|
|
68
|
+
return () => clearInterval(timer);
|
|
69
|
+
}, [date?.getTime()]);
|
|
70
|
+
|
|
71
|
+
if (!date) return <span className={className}>—</span>;
|
|
72
|
+
return (
|
|
73
|
+
<time dateTime={date.toISOString()} title={date.toISOString()} className={className} suppressHydrationWarning>
|
|
74
|
+
{text ?? new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" }).format(date)}
|
|
75
|
+
</time>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -19,7 +19,9 @@ export function hrefWith(path: string, current: URLSearchParams, changes: Record
|
|
|
19
19
|
if (value === null || value === "") next.delete(key);
|
|
20
20
|
else next.set(key, value);
|
|
21
21
|
}
|
|
22
|
+
// A cursor belongs to the query that produced it, so changing anything else drops it.
|
|
22
23
|
if (!("page" in changes)) next.delete("page");
|
|
24
|
+
if (!("cursor" in changes)) next.delete("cursor");
|
|
23
25
|
const query = next.toString();
|
|
24
26
|
return query ? `${path}?${query}` : path;
|
|
25
27
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useState, useTransition } from "react";
|
|
6
|
+
import { PencilIcon, Trash2Icon } from "lucide-react";
|
|
7
|
+
import { toast } from "sonner";
|
|
8
|
+
import type { ClientResource } from "@flaredev/core";
|
|
9
|
+
import { deleteRecordAction } from "@/app/dashboard/actions";
|
|
10
|
+
import {
|
|
11
|
+
AlertDialog,
|
|
12
|
+
AlertDialogCancel,
|
|
13
|
+
AlertDialogContent,
|
|
14
|
+
AlertDialogDescription,
|
|
15
|
+
AlertDialogFooter,
|
|
16
|
+
AlertDialogHeader,
|
|
17
|
+
AlertDialogTitle,
|
|
18
|
+
} from "@/components/ui/alert-dialog";
|
|
19
|
+
import { Button } from "@/components/ui/button";
|
|
20
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
21
|
+
import { ResourceFormDialog, type FormRelations } from "./resource-form-dialog";
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
resource: ClientResource;
|
|
25
|
+
id: string;
|
|
26
|
+
record: Record<string, unknown>;
|
|
27
|
+
relations: FormRelations;
|
|
28
|
+
listHref: string;
|
|
29
|
+
editHref: string;
|
|
30
|
+
canUpdate: boolean;
|
|
31
|
+
canDelete: boolean;
|
|
32
|
+
/** Whether editing opens a dialog (site.dashboard.forms) or goes to the form page. */
|
|
33
|
+
modalForms: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Edit and delete, at the top of a record's own page. Deleting goes back to the list. */
|
|
37
|
+
export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, modalForms }: Props) {
|
|
38
|
+
const router = useRouter();
|
|
39
|
+
const [editing, setEditing] = useState(false);
|
|
40
|
+
const [confirming, setConfirming] = useState(false);
|
|
41
|
+
const [pending, startTransition] = useTransition();
|
|
42
|
+
|
|
43
|
+
const remove = () => {
|
|
44
|
+
startTransition(async () => {
|
|
45
|
+
const result = await deleteRecordAction(resource.name, id);
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
toast.error(result.error);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
toast.success(`${resource.label} deleted.`);
|
|
51
|
+
// The record this page is about is gone, so the list is the only place left to be.
|
|
52
|
+
router.push(listHref);
|
|
53
|
+
router.refresh();
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
{canUpdate &&
|
|
60
|
+
(modalForms ? (
|
|
61
|
+
<Button variant="outline" onClick={() => setEditing(true)}>
|
|
62
|
+
<PencilIcon data-icon="inline-start" />
|
|
63
|
+
Edit
|
|
64
|
+
</Button>
|
|
65
|
+
) : (
|
|
66
|
+
<Button variant="outline" asChild>
|
|
67
|
+
<Link href={editHref}>
|
|
68
|
+
<PencilIcon data-icon="inline-start" />
|
|
69
|
+
Edit
|
|
70
|
+
</Link>
|
|
71
|
+
</Button>
|
|
72
|
+
))}
|
|
73
|
+
{canDelete && (
|
|
74
|
+
<Button variant="outline" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)}>
|
|
75
|
+
<Trash2Icon data-icon="inline-start" />
|
|
76
|
+
Delete
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{canUpdate && modalForms && (
|
|
81
|
+
<ResourceFormDialog
|
|
82
|
+
resource={resource}
|
|
83
|
+
relations={relations}
|
|
84
|
+
listHref={listHref}
|
|
85
|
+
mode="edit"
|
|
86
|
+
id={id}
|
|
87
|
+
record={record}
|
|
88
|
+
open={editing}
|
|
89
|
+
onOpenChange={(open) => {
|
|
90
|
+
setEditing(open);
|
|
91
|
+
if (!open) router.refresh();
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<AlertDialog open={confirming} onOpenChange={setConfirming}>
|
|
97
|
+
<AlertDialogContent>
|
|
98
|
+
<AlertDialogHeader>
|
|
99
|
+
<AlertDialogTitle>Delete this {resource.label.toLowerCase()}?</AlertDialogTitle>
|
|
100
|
+
<AlertDialogDescription>This can't be undone. Anything that belongs to it may be deleted too.</AlertDialogDescription>
|
|
101
|
+
</AlertDialogHeader>
|
|
102
|
+
<AlertDialogFooter>
|
|
103
|
+
<AlertDialogCancel disabled={pending}>Keep it</AlertDialogCancel>
|
|
104
|
+
<Button variant="destructive" onClick={remove} disabled={pending}>
|
|
105
|
+
{pending && <Spinner data-icon="inline-start" />}
|
|
106
|
+
Delete
|
|
107
|
+
</Button>
|
|
108
|
+
</AlertDialogFooter>
|
|
109
|
+
</AlertDialogContent>
|
|
110
|
+
</AlertDialog>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|