create-nevela 0.7.1 → 0.8.0

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 (50) hide show
  1. package/index.mjs +1 -0
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +25 -3
  5. package/template/laravel/src/Access/Permissions.php +36 -5
  6. package/template/laravel/src/Concerns/Trashable.php +23 -0
  7. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  8. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  9. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  10. package/template/laravel/src/Console/SeedCommand.php +3 -1
  11. package/template/laravel/src/Console/TrashCommand.php +8 -0
  12. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  13. package/template/laravel/src/Http/ActionController.php +222 -0
  14. package/template/laravel/src/Http/PublicController.php +210 -0
  15. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  16. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  17. package/template/laravel/src/Http/TrashController.php +18 -2
  18. package/template/laravel/src/Http/UploadController.php +42 -1
  19. package/template/laravel/src/Media/Uploads.php +111 -0
  20. package/template/laravel/src/Nevela.php +54 -2
  21. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  22. package/template/laravel/src/Support/Action.php +168 -0
  23. package/template/laravel/src/Support/Adoption.php +352 -0
  24. package/template/laravel/src/Support/Descriptor.php +59 -4
  25. package/template/laravel/src/Support/Fake.php +15 -1
  26. package/template/laravel/src/Support/Field.php +74 -3
  27. package/template/laravel/src/Support/Html.php +185 -0
  28. package/template/laravel/src/Support/PublicAccess.php +133 -0
  29. package/template/web/app/api/[...path]/route.ts +42 -9
  30. package/template/web/app/dashboard/actions.ts +34 -0
  31. package/template/web/app/globals.css +46 -0
  32. package/template/web/components/access/role-form.tsx +40 -5
  33. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  34. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  35. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  36. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  37. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  38. package/template/web/components/dashboard/record-actions.tsx +7 -2
  39. package/template/web/components/dashboard/record-detail.tsx +9 -2
  40. package/template/web/components/dashboard/resource-action.tsx +281 -0
  41. package/template/web/components/dashboard/resource-table.tsx +15 -2
  42. package/template/web/components/dashboard/row-actions.tsx +24 -0
  43. package/template/web/components/dashboard/table-selection.tsx +19 -1
  44. package/template/web/lib/access.ts +3 -1
  45. package/template/web/lib/dashboard.ts +10 -0
  46. package/template/web/lib/extras.ts +66 -0
  47. package/template/web/lib/files.ts +14 -0
  48. package/template/web/lib/laravel.ts +60 -0
  49. package/template/web/lib/rich.ts +79 -0
  50. 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,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,7 +18,8 @@ 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 } from "@/lib/extras";
20
23
  import { site } from "@/lib/site";
21
24
  import { trashFor } from "@/lib/trash";
22
25
  import { cn } from "@/lib/utils";
@@ -26,6 +29,7 @@ import { isInlineEditable } from "./editable";
26
29
  import { InlineCell } from "./inline-cell";
27
30
  import type { RelationMeta } from "./fields/field-widget";
28
31
  import { BulkCreateButton } from "./bulk-create-grid";
32
+ import { ActionButtons } from "./resource-action";
29
33
  import { ResourceInsights } from "./resource-insights";
30
34
  import { ImportDialog } from "./import-dialog";
31
35
  import { LocalTime } from "./local-time";
@@ -96,6 +100,9 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
96
100
  }
97
101
 
98
102
  const overlayForms = site.dashboard.forms === "sheet";
103
+ // The resource's own actions this person may run: on the list as a whole, and on a record.
104
+ const actions = await allowedActions(resource.name);
105
+ const onRecords = actions.filter((action) => action.on === "record");
99
106
  const rowIds = rows.map((row) => String(row.id));
100
107
  const exportColumns = [{ key: "id", label: "Id" }, ...fields.filter(([, def]) => def.kind !== "file").map(([key, def]) => ({ key, label: def.label }))];
101
108
 
@@ -115,6 +122,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
115
122
  <div className="flex flex-wrap items-center gap-2">
116
123
  <ColumnMenu columns={allColumns.map(({ key, label }) => ({ key, label }))} visible={columns.map((column) => column.key)} />
117
124
  <ExportButton resourceName={resource.name} pluralLabel={resource.pluralLabel} />
125
+ <ActionButtons resourceName={resource.name} actions={actions.filter((action) => action.on === "collection")} />
118
126
  {permissions.create && <ImportDialog resource={forClient} />}
119
127
  {permissions.create && <BulkCreateButton resource={forClient} relations={relations} />}
120
128
  {permissions.create &&
@@ -213,10 +221,13 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
213
221
  } else if (column.def.kind === "enum" && value != null) {
214
222
  const tone = statusTone(value);
215
223
  content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
224
+ } else if (isRich(column.def) && typeof value === "string") {
225
+ // The start of it, as words: markup is noise in a table.
226
+ content = richToText(value, 120);
216
227
  } else if (column.def.kind === "file" && typeof value === "string" && value) {
217
228
  // A thumbnail for an image, a filename for anything else — the raw
218
229
  // object key is noise in a table.
219
- content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} />;
230
+ content = <FileCell resourceName={resource.name} fieldKey={column.key} value={value} download={privateFileUrl(resource.slug, String(row.id), column.key)} />;
220
231
  } else if (column.def.kind === "multiselect" && Array.isArray(value) && value.length) {
221
232
  const def = column.def;
222
233
  content = (
@@ -304,6 +315,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
304
315
  canUpdate={permissions.update}
305
316
  canDelete={permissions.delete}
306
317
  trash={trash}
318
+ actions={onRecords.filter((action) => actionApplies(action, row))}
307
319
  />
308
320
  </TableCell>
309
321
  </TableRow>
@@ -398,6 +410,7 @@ export async function ResourceTable({ resource, searchParams }: { resource: Reso
398
410
  label={resource.label}
399
411
  pluralLabel={resource.pluralLabel}
400
412
  canDelete={permissions.delete}
413
+ actions={onRecords.filter((action) => action.many)}
401
414
  rows={rows}
402
415
  columns={exportColumns}
403
416
  slug={resource.slug}
@@ -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 { 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,7 @@ export function RowActions({
46
48
  canUpdate = true,
47
49
  canDelete = true,
48
50
  trash,
51
+ actions = [],
49
52
  }: {
50
53
  resource: ClientResource;
51
54
  id: string;
@@ -60,8 +63,11 @@ export function RowActions({
60
63
  canDelete?: boolean;
61
64
  /** This resource's trash, when a deleted record goes there instead of going for good. */
62
65
  trash?: TrashInfo;
66
+ /** The resource's own actions that this person may run and that apply to this row. */
67
+ actions?: ResourceAction[];
63
68
  }) {
64
69
  const router = useRouter();
70
+ const own = useResourceActions(resource.name, { one: resource.label.toLowerCase(), many: resource.pluralLabel.toLowerCase() });
65
71
  const [confirming, setConfirming] = useState(false);
66
72
  const [editing, setEditing] = useState(false);
67
73
  const [pending, startTransition] = useTransition();
@@ -99,6 +105,22 @@ export function RowActions({
99
105
  </DropdownMenuItem>
100
106
  </DropdownMenuGroup>
101
107
  <DropdownMenuSeparator />
108
+ {actions.length > 0 && (
109
+ <>
110
+ <DropdownMenuGroup>
111
+ {actions.map((action) => {
112
+ const Icon = actionIcon(action.icon);
113
+ return (
114
+ <DropdownMenuItem key={action.key} disabled={own.pending} onSelect={() => own.start(action, { id })}>
115
+ <Icon />
116
+ {action.label}
117
+ </DropdownMenuItem>
118
+ );
119
+ })}
120
+ </DropdownMenuGroup>
121
+ {(canUpdate || canDelete) && <DropdownMenuSeparator />}
122
+ </>
123
+ )}
102
124
  {canUpdate && (
103
125
  <DropdownMenuGroup>
104
126
  {record ? (
@@ -128,6 +150,8 @@ export function RowActions({
128
150
  </DropdownMenuContent>
129
151
  </DropdownMenu>
130
152
 
153
+ {own.dialogs}
154
+
131
155
  {record && canUpdate && (
132
156
  <ResourceFormSheet
133
157
  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>
@@ -51,8 +51,10 @@ export interface PermissionFeature {
51
51
  /** The first half of a permission: "products". */
52
52
  key: string;
53
53
  name: string;
54
- /** The second halves that mean something for it: create, view, edit, delete. */
54
+ /** The second halves that mean something for it: create, view, edit, delete, then the resource's own actions. */
55
55
  actions: string[];
56
+ /** What a resource's own actions are called, by second half: { accept: "Accept" }. */
57
+ labels?: Record<string, string>;
56
58
  }
57
59
 
58
60
  export interface PermissionModule {
@@ -1,3 +1,4 @@
1
+ import { actionsOf, type ResourceAction } from "@/lib/extras";
1
2
  import { cache } from "react";
2
3
  import { notFound, redirect } from "next/navigation";
3
4
  import { can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
@@ -96,6 +97,15 @@ export async function adminPermissions(resourceName: string): Promise<Record<Exc
96
97
  return { read: read!, create: create!, update: update!, delete: remove! };
97
98
  }
98
99
 
100
+ /**
101
+ * The resource's own actions that the current person may run, for drawing their buttons.
102
+ * Each is a permission by name. Laravel checks it again when the button is pressed.
103
+ */
104
+ export async function allowedActions(resourceName: string): Promise<ResourceAction[]> {
105
+ const { session, may } = await dashboardSession();
106
+ return session ? actionsOf(resourceName).filter((action) => may(action.permission)) : [];
107
+ }
108
+
99
109
  const stores = new Map<string, ResourceStore>();
100
110
 
101
111
  /** CRUD store for a resource by name (404 for unknown names). */
@@ -0,0 +1,66 @@
1
+ // What a resource has beyond its fields, which Flare's descriptor has no place for.
2
+ //
3
+ // Written by `php nevela generate` into resources/index.ts as `extras`, by resource
4
+ // name, from the descriptors in the Laravel app. Read through here, on the server: a
5
+ // client component is handed what it needs of it as props.
6
+
7
+ import * as registry from "@/resources";
8
+
9
+ /** A field an action asks for, as the descriptor has it. */
10
+ export interface ActionField {
11
+ kind: "string" | "text" | "int" | "float" | "boolean" | "date" | "datetime" | "enum";
12
+ format?: string;
13
+ options?: string[];
14
+ required?: boolean;
15
+ label?: string;
16
+ maxLength?: number;
17
+ }
18
+
19
+ /**
20
+ * Something that can be done to a record, or to the list, beyond creating, editing and
21
+ * deleting: the descriptor's "actions" section. What it does is a class in the Laravel
22
+ * app; this is what the dashboard needs to offer it.
23
+ */
24
+ export interface ResourceAction {
25
+ /** Its name in the descriptor and in the address: "accept". */
26
+ key: string;
27
+ /** The permission that allows it: "internship_applications.accept". */
28
+ permission: string;
29
+ label: string;
30
+ /** A lucide icon name. */
31
+ icon?: string;
32
+ /** Where its button is: on one record, or on the list as a whole. */
33
+ on: "record" | "collection";
34
+ /** A question asked before it runs. */
35
+ confirm?: string;
36
+ /** Field values a record must have for the action to apply to it. */
37
+ when?: Record<string, string | number | boolean | null>;
38
+ /** What it asks for, in a sheet. */
39
+ fields?: Record<string, ActionField>;
40
+ /** A record action that can also be run on the rows selected in the table. */
41
+ many?: boolean;
42
+ }
43
+
44
+ interface ResourceExtras {
45
+ actions?: Record<string, Omit<ResourceAction, "key">>;
46
+ }
47
+
48
+ /** An app generated before there were any has no `extras` in its registry, and none to have. */
49
+ const extras = ((registry as { extras?: unknown }).extras ?? {}) as Record<string, ResourceExtras>;
50
+
51
+ /** A resource's actions, in the order the descriptor gives them. */
52
+ export function actionsOf(resourceName: string): ResourceAction[] {
53
+ return Object.entries(extras[resourceName]?.actions ?? {}).map(([key, action]) => ({ ...action, key }));
54
+ }
55
+
56
+ /** Whether an action applies to a record as it is now. Laravel asks the same before running it. */
57
+ export function actionApplies(action: ResourceAction, record: Record<string, unknown>): boolean {
58
+ return Object.entries(action.when ?? {}).every(([field, wanted]) => {
59
+ const value = record[field] ?? null;
60
+ if (wanted === null) return value === null;
61
+ if (value === null) return false;
62
+ // A yes/no value may arrive as 1 or 0.
63
+ if (typeof wanted === "boolean") return (value === true || value === 1 || value === "1") === wanted;
64
+ return String(value) === String(wanted);
65
+ });
66
+ }
@@ -17,5 +17,19 @@ export function fileUrl(key: string, rendition?: string): string {
17
17
  return `/api/_nevela/files/${path.split("/").map(encodeURIComponent).join("/")}`;
18
18
  }
19
19
 
20
+ /**
21
+ * Whether a key is a private file's. Such a file has no address of its own: asked for by
22
+ * key, Laravel answers that there is no such file. It is fetched through its record.
23
+ */
24
+ export const isPrivateKey = (key: string) => key.startsWith("private/");
25
+
26
+ /**
27
+ * Where a private file is fetched from: the record it belongs to and the field it is in.
28
+ * Laravel hands it over only to someone signed in who may view that record.
29
+ */
30
+ export function privateFileUrl(resourceSlug: string, id: string, fieldKey: string): string {
31
+ return `/api/_nevela/private/${encodeURIComponent(resourceSlug)}/${encodeURIComponent(id)}/${encodeURIComponent(fieldKey)}`;
32
+ }
33
+
20
34
  /** "products/image/2026/10/<uuid>-kettle.webp" → "kettle.webp" */
21
35
  export const fileName = (key: string) => key.slice(key.lastIndexOf("/") + 1).replace(/^[0-9a-f-]{36}-/, "");
@@ -34,6 +34,17 @@ export async function callerHeaders(): Promise<Record<string, string>> {
34
34
  if (agent) sent["X-Nevela-User-Agent"] = agent.slice(0, 500);
35
35
  const address = (incoming.get("x-forwarded-for") ?? "").split(",")[0]?.trim() || incoming.get("x-real-ip");
36
36
  if (address) sent["X-Nevela-Ip"] = address.replace(/^::ffff:/, "");
37
+ // That one is for showing: the first entry of X-Forwarded-For is whatever the request
38
+ // came with, and anyone can send one. The address a visitor is *counted* by (the public
39
+ // routes' throttle) has to be one they couldn't have made up. Each proxy adds the
40
+ // address it was called from to the end of the list, so with one proxy in front of this
41
+ // app the last entry is the visitor as that proxy saw them. NEVELA_TRUSTED_PROXIES says
42
+ // how many proxies are in front: 1 unless set, and 0 when this app is reached directly,
43
+ // in which case nobody's word is taken and visitors are counted together.
44
+ const proxies = Number.parseInt(process.env.NEVELA_TRUSTED_PROXIES ?? "1", 10);
45
+ const chain = (incoming.get("x-forwarded-for") ?? "").split(",").map((part) => part.trim()).filter(Boolean);
46
+ const counted = Number.isInteger(proxies) && proxies > 0 ? chain[chain.length - proxies] : undefined;
47
+ sent["X-Nevela-Client"] = counted ? counted.replace(/^::ffff:/, "") : "unknown";
37
48
  // Proves to Laravel that this is the dashboard speaking for a browser, and not someone
38
49
  // calling the API with made-up headers. The same value is in the Laravel app's .env.
39
50
  if (process.env.NEVELA_PROXY_SECRET) sent["X-Nevela-Proxy-Secret"] = process.env.NEVELA_PROXY_SECRET;
@@ -80,3 +91,52 @@ export async function laravel(path: string, { method = "GET", body, token }: Lar
80
91
  }
81
92
  return { status: response.status, ok: response.ok, body: parsed };
82
93
  }
94
+
95
+ export interface PublicRequest {
96
+ /** How long an answer may be reused, in seconds. Default 300. `false` keeps it until one of its tags is revalidated. */
97
+ revalidate?: number | false;
98
+ /** What makes it stale. `resourceTag("Post")` from lib/cache: the dashboard revalidates it when a post is created, changed, deleted or restored. */
99
+ tags?: string[];
100
+ }
101
+
102
+ /**
103
+ * Read what a resource's "public" section opens, for a page anyone can visit.
104
+ *
105
+ * ```ts
106
+ * const { body } = await laravelPublic("posts?sort=-publishedAt", { tags: [resourceTag("Post")] });
107
+ * ```
108
+ *
109
+ * Unlike `laravel()` it carries nothing of the person asking: no token and none of their
110
+ * headers. The answer is the same for everyone, so it is cached and shared between
111
+ * visitors, and a published post appears as soon as the dashboard saves it, because
112
+ * saving revalidates the resource's tag.
113
+ *
114
+ * `path` is under `{api}/public/`. To send a form (a public create), post to
115
+ * `/api/public/<slug>` from the browser: see app/api/[...path]/route.ts.
116
+ */
117
+ export async function laravelPublic(path: string, { revalidate = 300, tags = [] }: PublicRequest = {}): Promise<LaravelResponse> {
118
+ let response: Response;
119
+ try {
120
+ response = await fetch(`${API_URL}/public/${path.replace(/^\/+/, "")}`, {
121
+ headers: {
122
+ Accept: "application/json",
123
+ // The site reading its own data, to show to many: Laravel doesn't count it against
124
+ // a visitor's allowance, which it otherwise would against this server's address.
125
+ ...(process.env.NEVELA_PROXY_SECRET ? { "X-Nevela-Proxy-Secret": process.env.NEVELA_PROXY_SECRET } : {}),
126
+ },
127
+ next: { revalidate, tags },
128
+ });
129
+ } catch {
130
+ return { status: 502, ok: false, body: { error: "Can't reach the API." } };
131
+ }
132
+ const text = response.status === 204 ? "" : await response.text();
133
+ let parsed: unknown = null;
134
+ if (text) {
135
+ try {
136
+ parsed = JSON.parse(text);
137
+ } catch {
138
+ parsed = { error: `Laravel answered ${response.status} with something that isn't JSON.` };
139
+ }
140
+ }
141
+ return { status: response.status, ok: response.ok, body: parsed };
142
+ }