create-nevela 0.7.1 → 0.9.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.
Files changed (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. package/template/web/package.json +1 -1
@@ -0,0 +1,281 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { useState, useTransition } from "react";
5
+ import {
6
+ ArchiveIcon,
7
+ BanIcon,
8
+ BellIcon,
9
+ CheckIcon,
10
+ CopyIcon,
11
+ DownloadIcon,
12
+ FlagIcon,
13
+ LockIcon,
14
+ MailIcon,
15
+ MegaphoneIcon,
16
+ PauseIcon,
17
+ PlayIcon,
18
+ RefreshCwIcon,
19
+ SendIcon,
20
+ StarIcon,
21
+ UnlockIcon,
22
+ UploadIcon,
23
+ XIcon,
24
+ ZapIcon,
25
+ type LucideIcon,
26
+ } from "lucide-react";
27
+ import { toast } from "sonner";
28
+ import { field as build, type StoredField } from "@flaredev/core";
29
+ import { runResourceAction } from "@/app/dashboard/actions";
30
+ import {
31
+ AlertDialog,
32
+ AlertDialogCancel,
33
+ AlertDialogContent,
34
+ AlertDialogDescription,
35
+ AlertDialogFooter,
36
+ AlertDialogHeader,
37
+ AlertDialogTitle,
38
+ } from "@/components/ui/alert-dialog";
39
+ import { Button } from "@/components/ui/button";
40
+ import { Field, FieldError, FieldLabel } from "@/components/ui/field";
41
+ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
42
+ import { Spinner } from "@/components/ui/spinner";
43
+ import type { ActionField, ResourceAction } from "@/lib/extras";
44
+ import { FieldWidget } from "./fields/field-widget";
45
+
46
+ const ICONS: Record<string, LucideIcon> = {
47
+ archive: ArchiveIcon,
48
+ ban: BanIcon,
49
+ bell: BellIcon,
50
+ check: CheckIcon,
51
+ copy: CopyIcon,
52
+ download: DownloadIcon,
53
+ flag: FlagIcon,
54
+ lock: LockIcon,
55
+ mail: MailIcon,
56
+ megaphone: MegaphoneIcon,
57
+ pause: PauseIcon,
58
+ play: PlayIcon,
59
+ refresh: RefreshCwIcon,
60
+ send: SendIcon,
61
+ star: StarIcon,
62
+ unlock: UnlockIcon,
63
+ upload: UploadIcon,
64
+ x: XIcon,
65
+ };
66
+
67
+ /** The icon an action names, or a general one for "do something". */
68
+ export function actionIcon(name: string | undefined): LucideIcon {
69
+ return (name && ICONS[name]) || ZapIcon;
70
+ }
71
+
72
+ /** What an action is run on: one record, the rows selected, or nothing in particular (the list as a whole). */
73
+ export interface ActionTarget {
74
+ id?: string;
75
+ ids?: string[];
76
+ }
77
+
78
+ const words = (key: string) => {
79
+ const spaced = key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
80
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
81
+ };
82
+
83
+ /** A field an action asks for, as the form's widgets know fields. */
84
+ function widgetField(key: string, def: ActionField): StoredField & { label: string } {
85
+ const options = { required: def.required !== false, ...(def.maxLength ? { maxLength: def.maxLength } : {}) };
86
+ const made =
87
+ def.kind === "enum"
88
+ ? build.enum((def.options?.length ? def.options : [""]) as [string, ...string[]], { required: options.required })
89
+ : def.kind === "string" && def.format === "email"
90
+ ? build.email(options)
91
+ : def.kind === "string" && def.format === "url"
92
+ ? build.url(options)
93
+ : def.kind === "string" && def.format === "tel"
94
+ ? build.tel(options)
95
+ : def.kind === "string"
96
+ ? build.string(options)
97
+ : def.kind === "text"
98
+ ? build.text(options)
99
+ : def.kind === "int"
100
+ ? build.int({ required: options.required })
101
+ : def.kind === "float"
102
+ ? build.float({ required: options.required })
103
+ : def.kind === "boolean"
104
+ ? build.boolean({ required: options.required })
105
+ : def.kind === "date"
106
+ ? build.date({ required: options.required })
107
+ : build.datetime({ required: options.required });
108
+ return { ...made, label: def.label ?? words(key) } as StoredField & { label: string };
109
+ }
110
+
111
+ /** What the widgets hold while typing, as the API wants it: a number as a number, nothing as nothing. */
112
+ function asInput(fields: Record<string, ActionField>, values: Record<string, string | boolean>): Record<string, unknown> {
113
+ const input: Record<string, unknown> = {};
114
+ for (const [key, def] of Object.entries(fields)) {
115
+ const value = values[key];
116
+ if (def.kind === "boolean") input[key] = value === true;
117
+ else if (value === undefined || value === "") continue;
118
+ else if (def.kind === "int" || def.kind === "float") input[key] = Number(value);
119
+ else input[key] = value;
120
+ }
121
+ return input;
122
+ }
123
+
124
+ /**
125
+ * Running a resource's own actions from wherever their buttons are: a record's page, a
126
+ * row's menu, beside New, the selection bar.
127
+ *
128
+ * `start` does what the action calls for first. One with something to fill in opens a
129
+ * sheet; one with a question asks it, in the dialog Delete uses; one with neither runs.
130
+ * Then the message its class returned is the toast, and the page is asked for again.
131
+ * `dialogs` is the sheet and the dialog, to be rendered once by whoever holds the buttons.
132
+ */
133
+ export function useResourceActions(resourceName: string, nouns: { one: string; many: string }, onDone?: () => void) {
134
+ const router = useRouter();
135
+ const [current, setCurrent] = useState<{ action: ResourceAction; target: ActionTarget } | null>(null);
136
+ const [values, setValues] = useState<Record<string, string | boolean>>({});
137
+ const [issues, setIssues] = useState<Record<string, string>>({});
138
+ const [pending, startTransition] = useTransition();
139
+
140
+ function run(action: ResourceAction, target: ActionTarget, input: Record<string, unknown>) {
141
+ startTransition(async () => {
142
+ const result = await runResourceAction(resourceName, action.key, target, input);
143
+ if (result.ok) {
144
+ toast.success(result.data.message);
145
+ setCurrent(null);
146
+ onDone?.();
147
+ router.refresh();
148
+ return;
149
+ }
150
+ const byField: Record<string, string> = {};
151
+ for (const issue of result.issues ?? []) byField[issue.path] ??= issue.message;
152
+ // A problem with something that was filled in is shown where it was filled in.
153
+ if (action.fields && Object.keys(byField).some((key) => key in action.fields!)) {
154
+ setIssues(byField);
155
+ return;
156
+ }
157
+ toast.error(result.error);
158
+ setCurrent(null);
159
+ // It may have stopped applying while the page was open: show the page as it is now.
160
+ if (result.status === 409 || result.status === 404) router.refresh();
161
+ });
162
+ }
163
+
164
+ function start(action: ResourceAction, target: ActionTarget = {}) {
165
+ setIssues({});
166
+ setValues(Object.fromEntries(Object.entries(action.fields ?? {}).map(([key, def]) => [key, def.kind === "boolean" ? false : ""])));
167
+ if (!action.fields && !action.confirm) run(action, target, {});
168
+ else setCurrent({ action, target });
169
+ }
170
+
171
+ const action = current?.action;
172
+ const count = current?.target.ids?.length;
173
+ // "the 1 article you selected", "the 3 articles you selected"
174
+ const selected = count ? ` This is for the ${count} ${count === 1 ? nouns.one : nouns.many} you selected.` : "";
175
+ const asking = Boolean(action && !action.fields);
176
+ const filling = Boolean(action?.fields);
177
+
178
+ const dialogs = (
179
+ <>
180
+ <AlertDialog open={asking} onOpenChange={(open) => !open && !pending && setCurrent(null)}>
181
+ <AlertDialogContent>
182
+ <AlertDialogHeader>
183
+ <AlertDialogTitle>{action?.label}</AlertDialogTitle>
184
+ <AlertDialogDescription>
185
+ {action?.confirm}
186
+ {selected}
187
+ </AlertDialogDescription>
188
+ </AlertDialogHeader>
189
+ <AlertDialogFooter>
190
+ <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
191
+ <Button onClick={() => current && run(current.action, current.target, {})} disabled={pending}>
192
+ {pending && <Spinner data-icon="inline-start" />}
193
+ {action?.label}
194
+ </Button>
195
+ </AlertDialogFooter>
196
+ </AlertDialogContent>
197
+ </AlertDialog>
198
+
199
+ <Sheet open={filling} onOpenChange={(open) => !open && !pending && setCurrent(null)}>
200
+ <SheetContent className="flex w-full flex-col gap-0 sm:max-w-md">
201
+ <SheetHeader>
202
+ <SheetTitle>{action?.label}</SheetTitle>
203
+ <SheetDescription>
204
+ {action?.confirm ?? `Fill this in, then ${action?.label.toLowerCase()}.`}
205
+ {selected}
206
+ </SheetDescription>
207
+ </SheetHeader>
208
+ <form
209
+ className="flex min-h-0 flex-1 flex-col"
210
+ onSubmit={(event) => {
211
+ event.preventDefault();
212
+ if (current?.action.fields) run(current.action, current.target, asInput(current.action.fields, values));
213
+ }}
214
+ >
215
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
216
+ {Object.entries(action?.fields ?? {}).map(([key, def]) => {
217
+ const widget = widgetField(key, def);
218
+ const id = `action-${action?.key}-${key}`;
219
+ return (
220
+ <Field key={key} data-invalid={issues[key] ? true : undefined} orientation={def.kind === "boolean" ? "horizontal" : "vertical"}>
221
+ <FieldLabel htmlFor={id}>
222
+ {widget.label}
223
+ {def.required !== false && def.kind !== "boolean" && <span className="text-destructive"> *</span>}
224
+ </FieldLabel>
225
+ <FieldWidget
226
+ id={id}
227
+ name={key}
228
+ resourceName={resourceName}
229
+ fieldKey={key}
230
+ field={widget}
231
+ value={values[key] ?? ""}
232
+ onChange={(value) => setValues((before) => ({ ...before, [key]: value }))}
233
+ invalid={Boolean(issues[key])}
234
+ disabled={pending}
235
+ />
236
+ {issues[key] && <FieldError>{issues[key]}</FieldError>}
237
+ </Field>
238
+ );
239
+ })}
240
+ </div>
241
+ <SheetFooter className="flex-row justify-end border-t">
242
+ <Button type="button" variant="outline" onClick={() => setCurrent(null)} disabled={pending}>
243
+ Cancel
244
+ </Button>
245
+ <Button type="submit" disabled={pending}>
246
+ {pending && <Spinner data-icon="inline-start" />}
247
+ {action?.label}
248
+ </Button>
249
+ </SheetFooter>
250
+ </form>
251
+ </SheetContent>
252
+ </Sheet>
253
+ </>
254
+ );
255
+
256
+ return { start, pending, dialogs };
257
+ }
258
+
259
+ /**
260
+ * Buttons for a resource's actions: on a record's page (with the record's id), or beside
261
+ * New for actions on the list as a whole (with none).
262
+ */
263
+ export function ActionButtons({ resourceName, actions, id }: { resourceName: string; actions: ResourceAction[]; id?: string }) {
264
+ // Buttons here are for one record or for the list as a whole: never for a selection, so no noun is needed.
265
+ const { start, pending, dialogs } = useResourceActions(resourceName, { one: "record", many: "records" });
266
+ if (actions.length === 0) return null;
267
+ return (
268
+ <>
269
+ {actions.map((action) => {
270
+ const Icon = actionIcon(action.icon);
271
+ return (
272
+ <Button key={action.key} variant="outline" disabled={pending} onClick={() => start(action, id ? { id } : {})}>
273
+ <Icon data-icon="inline-start" />
274
+ {action.label}
275
+ </Button>
276
+ );
277
+ })}
278
+ {dialogs}
279
+ </>
280
+ );
281
+ }
@@ -3,7 +3,7 @@
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { useMemo, useState, useTransition, type FormEvent, type MouseEvent } from "react";
6
- import { createValidators, formValuesToInput, initialFormValues, issuesByField, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
6
+ import { createValidators, formValuesToInput, initialFormValues, issuesByField, slugify, storedFields, type ClientResource, type StoredField } from "@flaredev/core";
7
7
  import { toast } from "sonner";
8
8
  import { createRecordAction, updateRecordAction } from "@/app/dashboard/actions";
9
9
  import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -70,8 +70,25 @@ export function ResourceForm({ resource, mode, id, record, relations = {}, listH
70
70
  const last = step === steps.length - 1;
71
71
  const visible = steps[step] ?? [];
72
72
 
73
+ /**
74
+ * Fields made from another (a slug from a title) and not yet typed in by hand. On a new
75
+ * record they follow their source as it is typed, so what Laravel would make is on
76
+ * show; once someone types in one, it is theirs. Never on an edit: a slug that is
77
+ * already an address doesn't move.
78
+ */
79
+ const [following, setFollowing] = useState<Set<string>>(
80
+ () => new Set(mode === "create" ? fields.filter(([, def]) => typeof (def as { from?: string }).from === "string").map(([key]) => key) : []),
81
+ );
82
+
73
83
  const setValue = (key: string, value: string | boolean) => {
74
- setValues((current) => ({ ...current, [key]: value }));
84
+ setValues((current) => {
85
+ const next = { ...current, [key]: value };
86
+ for (const [made, def] of fields) {
87
+ if (following.has(made) && (def as { from?: string }).from === key) next[made] = slugify(String(value));
88
+ }
89
+ return next;
90
+ });
91
+ if (following.has(key)) setFollowing((current) => new Set([...current].filter((made) => made !== key)));
75
92
  if (errors[key]) setErrors(({ [key]: _removed, ...rest }) => rest);
76
93
  };
77
94
 
@@ -12,7 +12,7 @@ import { hrefWith } from "./query";
12
12
  const ALL = "__all__";
13
13
 
14
14
  /** Search box and enum/boolean filters for ResourceTable; all state goes into the URL. */
15
- export function ResourceTableToolbar({ resource }: { resource: ClientResource }) {
15
+ export function ResourceTableToolbar({ resource, publish }: { resource: ClientResource; publish?: { field: string; label: string } }) {
16
16
  const router = useRouter();
17
17
  const pathname = usePathname();
18
18
  const params = new URLSearchParams(useSearchParams().toString());
@@ -31,7 +31,7 @@ export function ResourceTableToolbar({ resource }: { resource: ClientResource })
31
31
  go({ q: q.trim() || null });
32
32
  }
33
33
 
34
- if (!searchable && filters.length === 0) return null;
34
+ if (!searchable && filters.length === 0 && !publish) return null;
35
35
 
36
36
  return (
37
37
  <div className="flex flex-wrap items-center gap-2">
@@ -52,6 +52,24 @@ export function ResourceTableToolbar({ resource }: { resource: ClientResource })
52
52
  </form>
53
53
  )}
54
54
 
55
+ {/* For a resource with a publish date: which of the three a record is, not a date to match. */}
56
+ {publish && (
57
+ <Select value={params.get(`filter[${publish.field}]`) ?? ALL} onValueChange={(value) => go({ [`filter[${publish.field}]`]: value === ALL ? null : value })}>
58
+ <SelectTrigger className="min-w-36" aria-label="Filter by whether it is published">
59
+ <span className="text-muted-foreground">Status:</span>
60
+ <SelectValue />
61
+ </SelectTrigger>
62
+ <SelectContent>
63
+ <SelectGroup>
64
+ <SelectItem value={ALL}>Any</SelectItem>
65
+ <SelectItem value="published">Published</SelectItem>
66
+ <SelectItem value="scheduled">Scheduled</SelectItem>
67
+ <SelectItem value="draft">Draft</SelectItem>
68
+ </SelectGroup>
69
+ </SelectContent>
70
+ </Select>
71
+ )}
72
+
55
73
  {filters.map(([key, def]) => {
56
74
  const param = `filter[${key}]`;
57
75
  const options = def.kind === "enum" ? def.options.map((value) => ({ value, label: optionLabel(def, value) })) : [
@@ -3,6 +3,8 @@ import { FileCell } from "@/components/dashboard/fields/file-cell";
3
3
  import { ArrowDownIcon, ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsUpDownIcon, PlusIcon } from "lucide-react";
4
4
  import { clientResource, formatValue, optionLabel, statusTone, storedFields, type Resource, type StoredField } from "@flaredev/core";
5
5
  import { isSortable } from "@/lib/resource/query";
6
+ import { privateFileUrl } from "@/lib/files";
7
+ import { isRich, richToText } from "@/lib/rich";
6
8
  import { Alert, AlertDescription } from "@/components/ui/alert";
7
9
  import { Badge } from "@/components/ui/badge";
8
10
  import { Button } from "@/components/ui/button";
@@ -16,16 +18,19 @@ import {
16
18
  PaginationPrevious,
17
19
  } from "@/components/ui/pagination";
18
20
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
19
- import { resourcePath, adminPermissions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
21
+ import { resourcePath, adminPermissions, allowedActions, allResources, dashboardStore, requireAccess } from "@/lib/dashboard";
22
+ import { actionApplies, formOf, PUBLISH_LABELS, publishFieldOf, publishState } from "@/lib/extras";
20
23
  import { site } from "@/lib/site";
21
24
  import { trashFor } from "@/lib/trash";
22
25
  import { cn } from "@/lib/utils";
23
26
  import { ColumnMenu } from "./column-menu";
24
27
  import { ExportButton } from "./export-button";
28
+ import { FormActivity } from "./form-activity";
25
29
  import { isInlineEditable } from "./editable";
26
30
  import { InlineCell } from "./inline-cell";
27
31
  import type { RelationMeta } from "./fields/field-widget";
28
32
  import { BulkCreateButton } from "./bulk-create-grid";
33
+ import { ActionButtons } from "./resource-action";
29
34
  import { ResourceInsights } from "./resource-insights";
30
35
  import { ImportDialog } from "./import-dialog";
31
36
  import { LocalTime } from "./local-time";
@@ -96,6 +101,14 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
96
101
  }
97
102
 
98
103
  const overlayForms = site.dashboard.forms === "sheet";
104
+ // The field this resource's records are published by, where it has one, and the time to judge them by.
105
+ const publishField = publishFieldOf(resource.name);
106
+ const now = new Date();
107
+ // The field holding an address its owner has to prove, where the public form asks for that.
108
+ const confirmField = formOf(resource.name)?.confirm;
109
+ // The resource's own actions this person may run: on the list as a whole, and on a record.
110
+ const actions = await allowedActions(resource.name);
111
+ const onRecords = actions.filter((action) => action.on === "record");
99
112
  const rowIds = rows.map((row) => String(row.id));
100
113
  const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
101
114
 
@@ -110,11 +123,13 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
110
123
  <TableSelection ids={rowIds}>
111
124
  <div className="flex flex-col gap-4">
112
125
  <ResourceInsights resource={forClient} total={meta.total} />
126
+ <FormActivity resource={resource} />
113
127
  <div className="flex flex-wrap items-center justify-between gap-2">
114
- <ResourceTableToolbar resource={forClient} />
128
+ <ResourceTableToolbar resource={forClient} publish={publishField ? { field: publishField, label: resource.fields[publishField]?.label ?? "Published" } : undefined} />
115
129
  <div className="flex flex-wrap items-center gap-2">
116
130
  <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
117
131
  <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
132
+ <ActionButtons resourceName={resource.name} actions={actions.filter((action) => action.on === "collection")} />
118
133
  {permissions.create && <ImportDialog resource={forClient} />}
119
134
  {permissions.create && <BulkCreateButton resource={forClient} relations={relations} />}
120
135
  {permissions.create &&
@@ -208,15 +223,39 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
208
223
  let content: React.ReactNode = formatValue(column.def, value);
209
224
  // Timestamps are formatted in the browser: the Worker's clock is UTC,
210
225
  // so a date rendered here would be in nobody's timezone.
211
- if ((column.def.kind === "timestamp" || column.def.kind === "datetime") && value != null) {
226
+ if (column.key === publishField) {
227
+ // Not the raw date: what it means. A post is published, waiting for its time, or neither.
228
+ const state = publishState(value, now);
229
+ content = (
230
+ <span className="inline-flex items-center gap-2">
231
+ <Badge variant={state === "published" ? "success" : state === "scheduled" ? "warning" : "secondary"}>{PUBLISH_LABELS[state]}</Badge>
232
+ {state !== "draft" && (
233
+ <span className="text-xs text-muted-foreground">
234
+ <LocalTime value={value as string} />
235
+ </span>
236
+ )}
237
+ </span>
238
+ );
239
+ } else if (column.key === confirmField && row.confirmedAt == null) {
240
+ // Joined through the form and hasn't followed the link yet: listed, and not counted or exported.
241
+ content = (
242
+ <span className="inline-flex items-center gap-2">
243
+ {content}
244
+ <Badge variant="warning">Not confirmed</Badge>
245
+ </span>
246
+ );
247
+ } else if ((column.def.kind === "timestamp" || column.def.kind === "datetime") && value != null) {
212
248
  content = <LocalTime value={value as string | number | Date} />;
213
249
  } else if (column.def.kind === "enum" && value != null) {
214
250
  const tone = statusTone(value);
215
251
  content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
252
+ } else if (isRich(column.def) && typeof value === "string") {
253
+ // The start of it, as words: markup is noise in a table.
254
+ content = richToText(value, 120);
216
255
  } else if (column.def.kind === "file" && typeof value === "string" && value) {
217
256
  // A thumbnail for an image, a filename for anything else — the raw
218
257
  // object key is noise in a table.
219
- content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} />;
258
+ content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} download={privateFileUrl(resource.slug, String(row.id), column.key)} />;
220
259
  } else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
221
260
  const def = column.def;
222
261
  content = (
@@ -304,6 +343,8 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
304
343
  canUpdate={permissions.update}
305
344
  canDelete={permissions.delete}
306
345
  trash={trash}
346
+ actions={onRecords.filter((action) => actionApplies(action, row))}
347
+ published={publishField ? publishState(row[publishField], now) : undefined}
307
348
  />
308
349
  </TableCell>
309
350
  </TableRow>
@@ -398,6 +439,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
398
439
  label={resource.label}
399
440
  pluralLabel={resource.pluralLabel}
400
441
  canDelete={permissions.delete}
442
+ actions={onRecords.filter((action) => action.many)}
401
443
  rows={rows}
402
444
  columns={exportColumns}
403
445
  slug={resource.slug}
@@ -3,10 +3,10 @@
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
5
  import { useState, useTransition } from "react";
6
- import { ExternalLinkIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react";
6
+ import { ExternalLinkIcon, EyeOffIcon, MoreHorizontalIcon, PencilIcon, SendIcon, Trash2Icon } from "lucide-react";
7
7
  import { toast } from "sonner";
8
8
  import type { ClientResource } from "@flaredev/core";
9
- import { deleteRecordAction } from "@/app/dashboard/actions";
9
+ import { deleteRecordAction, publishRecordAction } from "@/app/dashboard/actions";
10
10
  import { restoreRecordAction } from "@/app/dashboard/trash/actions";
11
11
  import {
12
12
  AlertDialog,
@@ -27,7 +27,9 @@ import {
27
27
  DropdownMenuTrigger,
28
28
  } from "@/components/ui/dropdown-menu";
29
29
  import { Spinner } from "@/components/ui/spinner";
30
+ import type { PublishState, ResourceAction } from "@/lib/extras";
30
31
  import { keptFor, type TrashInfo } from "@/lib/trash-info";
32
+ import { actionIcon, useResourceActions } from "./resource-action";
31
33
  import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
32
34
  import { toastMovedToTrash } from "./trash-actions";
33
35
 
@@ -46,6 +48,8 @@ export function RowActions({
46
48
  canUpdate = true,
47
49
  canDelete = true,
48
50
  trash,
51
+ actions = [],
52
+ published,
49
53
  }: {
50
54
  resource: ClientResource;
51
55
  id: string;
@@ -60,8 +64,22 @@ export function RowActions({
60
64
  canDelete?: boolean;
61
65
  /** This resource's trash, when a deleted record goes there instead of going for good. */
62
66
  trash?: TrashInfo;
67
+ /** The resource's own actions that this person may run and that apply to this row. */
68
+ actions?: ResourceAction[];
69
+ /** For a resource with a publish date: what this row is now. */
70
+ published?: PublishState;
63
71
  }) {
64
72
  const router = useRouter();
73
+ const [, startPublishing] = useTransition();
74
+ const publish = (now: boolean) => {
75
+ startPublishing(async () => {
76
+ const result = await publishRecordAction(resource.name, id, now);
77
+ if (result.ok) toast.success(now ? `${resource.label} published.` : `${resource.label} is a draft again.`);
78
+ else toast.error(result.error);
79
+ router.refresh();
80
+ });
81
+ };
82
+ const own = useResourceActions(resource.name, { one: resource.label.toLowerCase(), many: resource.pluralLabel.toLowerCase() });
65
83
  const [confirming, setConfirming] = useState(false);
66
84
  const [editing, setEditing] = useState(false);
67
85
  const [pending, startTransition] = useTransition();
@@ -99,6 +117,40 @@ export function RowActions({
99
117
  </DropdownMenuItem>
100
118
  </DropdownMenuGroup>
101
119
  <DropdownMenuSeparator />
120
+ {canUpdate && published && (
121
+ <>
122
+ <DropdownMenuGroup>
123
+ {published === "published" ? (
124
+ <DropdownMenuItem onSelect={() => publish(false)}>
125
+ <EyeOffIcon />
126
+ Unpublish
127
+ </DropdownMenuItem>
128
+ ) : (
129
+ <DropdownMenuItem onSelect={() => publish(true)}>
130
+ <SendIcon />
131
+ {published === "scheduled" ? "Publish now" : "Publish"}
132
+ </DropdownMenuItem>
133
+ )}
134
+ </DropdownMenuGroup>
135
+ <DropdownMenuSeparator />
136
+ </>
137
+ )}
138
+ {actions.length > 0 && (
139
+ <>
140
+ <DropdownMenuGroup>
141
+ {actions.map((action) => {
142
+ const Icon = actionIcon(action.icon);
143
+ return (
144
+ <DropdownMenuItem key={action.key} disabled={own.pending} onSelect={() => own.start(action, { id })}>
145
+ <Icon />
146
+ {action.label}
147
+ </DropdownMenuItem>
148
+ );
149
+ })}
150
+ </DropdownMenuGroup>
151
+ {(canUpdate || canDelete) && <DropdownMenuSeparator />}
152
+ </>
153
+ )}
102
154
  {canUpdate && (
103
155
  <DropdownMenuGroup>
104
156
  {record ? (
@@ -128,6 +180,8 @@ export function RowActions({
128
180
  </DropdownMenuContent>
129
181
  </DropdownMenu>
130
182
 
183
+ {own.dialogs}
184
+
131
185
  {record && canUpdate && (
132
186
  <ResourceFormSheet
133
187
  resource={resource}
@@ -20,7 +20,9 @@ import { Button } from "@/components/ui/button";
20
20
  import { Checkbox } from "@/components/ui/checkbox";
21
21
  import { Spinner } from "@/components/ui/spinner";
22
22
  import { toCsv } from "@/lib/csv";
23
+ import { actionApplies, type ResourceAction } from "@/lib/extras";
23
24
  import { keptFor, type TrashInfo } from "@/lib/trash-info";
25
+ import { actionIcon, useResourceActions } from "./resource-action";
24
26
  import { toastMovedToTrash } from "./trash-actions";
25
27
 
26
28
  interface SelectionValue {
@@ -100,15 +102,18 @@ interface BarProps {
100
102
  /** The resource's address, and its trash when deleted records go there instead of going for good. */
101
103
  slug: string;
102
104
  trash?: TrashInfo;
105
+ /** The resource's own actions that can be run on several records at once, and that this person may run. */
106
+ actions?: ResourceAction[];
103
107
  }
104
108
 
105
109
  /**
106
110
  * What you can do with the rows you've ticked. Appears only when something is selected,
107
111
  * pinned to the bottom so it's reachable in a long table.
108
112
  */
109
- export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows, columns, slug, trash }: BarProps) {
113
+ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows, columns, slug, trash, actions = [] }: BarProps) {
110
114
  const { selected, clear } = useSelection();
111
115
  const router = useRouter();
116
+ const own = useResourceActions(resourceName, { one: label.toLowerCase(), many: pluralLabel.toLowerCase() }, clear);
112
117
  const [confirming, setConfirming] = useState(false);
113
118
  const [pending, startTransition] = useTransition();
114
119
  const count = selected.size;
@@ -157,6 +162,17 @@ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows
157
162
  <DownloadIcon data-icon="inline-start" />
158
163
  Export
159
164
  </Button>
165
+ {actions.map((action) => {
166
+ const Icon = actionIcon(action.icon);
167
+ // Offered when it applies to at least one of the rows ticked. Laravel passes over the rest.
168
+ const applicable = rows.filter((row) => selected.has(String(row.id)) && actionApplies(action, row)).map((row) => String(row.id));
169
+ return (
170
+ <Button key={action.key} variant="ghost" size="sm" disabled={own.pending || applicable.length === 0} onClick={() => own.start(action, { ids: applicable })}>
171
+ <Icon data-icon="inline-start" />
172
+ {action.label}
173
+ </Button>
174
+ );
175
+ })}
160
176
  {canDelete && (
161
177
  <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setConfirming(true)}>
162
178
  <Trash2Icon data-icon="inline-start" />
@@ -168,6 +184,8 @@ export function SelectionBar({ resourceName, label, pluralLabel, canDelete, rows
168
184
  </Button>
169
185
  </div>
170
186
 
187
+ {own.dialogs}
188
+
171
189
  <AlertDialog open={confirming} onOpenChange={setConfirming}>
172
190
  <AlertDialogContent>
173
191
  <AlertDialogHeader>
@@ -1,5 +1,6 @@
1
1
  import Link from "next/link";
2
2
  import { BrandMark } from "@/components/auth/auth-shell";
3
+ import { siteLinks } from "@/lib/extras";
3
4
  import { site } from "@/lib/site";
4
5
 
5
6
  /** The public site's top bar. Static on purpose: it doesn't read the session, so the home page can be cached. */
@@ -12,12 +13,18 @@ export function SiteHeader() {
12
13
  {site.name}
13
14
  </Link>
14
15
  <nav aria-label="Main" className="hidden items-center gap-7 text-sm text-foreground-muted md:flex">
15
- <a href="#features" className="hover:text-foreground">
16
+ <a href="/#features" className="hover:text-foreground">
16
17
  Features
17
18
  </a>
18
- <a href="#customers" className="hover:text-foreground">
19
+ <a href="/#customers" className="hover:text-foreground">
19
20
  Customers
20
21
  </a>
22
+ {/* What the app's resources have put on the site: a blog, when `nevela blog` has made one. */}
23
+ {siteLinks().map((link) => (
24
+ <Link key={link.href} href={link.href} className="hover:text-foreground">
25
+ {link.label}
26
+ </Link>
27
+ ))}
21
28
  <a href={site.links.support} className="hover:text-foreground">
22
29
  Contact
23
30
  </a>