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
@@ -253,3 +253,49 @@
253
253
  letter-spacing: var(--heading-tracking);
254
254
  }
255
255
  }
256
+
257
+ /*
258
+ * Rich text: what a rich text field's editor holds and what a record's page shows of it.
259
+ * Tailwind's reset takes the looks off headings, lists and quotes; this gives them back.
260
+ */
261
+ .rich-text {
262
+ line-height: 1.6;
263
+ overflow-wrap: anywhere;
264
+ }
265
+ .rich-text > * + * {
266
+ margin-top: 0.75em;
267
+ }
268
+ .rich-text h2 {
269
+ font-size: 1.25em;
270
+ line-height: 1.3;
271
+ }
272
+ .rich-text h3 {
273
+ font-size: 1.1em;
274
+ line-height: 1.35;
275
+ }
276
+ .rich-text ul,
277
+ .rich-text ol {
278
+ padding-left: 1.5em;
279
+ }
280
+ .rich-text ul {
281
+ list-style: disc;
282
+ }
283
+ .rich-text ol {
284
+ list-style: decimal;
285
+ }
286
+ .rich-text li + li {
287
+ margin-top: 0.25em;
288
+ }
289
+ .rich-text blockquote {
290
+ border-left: 3px solid var(--border);
291
+ padding-left: 1em;
292
+ color: var(--muted-foreground);
293
+ }
294
+ .rich-text a {
295
+ color: var(--primary);
296
+ text-decoration: underline;
297
+ text-underline-offset: 2px;
298
+ }
299
+ .rich-text s {
300
+ text-decoration: line-through;
301
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import Link from "next/link";
4
4
  import { useRouter } from "next/navigation";
5
- import { useMemo, useState, useTransition } from "react";
5
+ import { Fragment, useMemo, useState, useTransition } from "react";
6
6
  import { toast } from "sonner";
7
7
  import { createRoleAction, deleteRoleAction, updateRoleAction } from "@/app/dashboard/access/actions";
8
8
  import {
@@ -27,6 +27,12 @@ import type { ManagedRole, PermissionCatalog, PermissionFeature } from "@/lib/ac
27
27
  const LIST = "/dashboard/access/roles";
28
28
  const ACTIONS = ["create", "view", "edit", "delete"] as const;
29
29
  const ACTION_LABELS: Record<string, string> = { create: "Create", view: "View", edit: "Edit", delete: "Delete" };
30
+ /**
31
+ * Whether a permission is one of a resource's own actions ("applications.accept") and not
32
+ * one of the four. Those are given by name only: a whole section, or a pattern, doesn't
33
+ * carry them, which is how Laravel reads a grant too.
34
+ */
35
+ const isOwn = (key: string) => !(ACTIONS as readonly string[]).includes(key.split(".")[1] ?? "");
30
36
 
31
37
  /**
32
38
  * A role's name and what it allows.
@@ -84,8 +90,9 @@ export function RoleForm({
84
90
  }, [kept.length]);
85
91
 
86
92
  const holds = (key: string) => mine === null || mine.includes(key);
87
- const on = (key: string) => everything || whole.has(moduleOf.get(key.split(".")[0]!) ?? "") || byPattern.has(key) || picked.has(key);
88
- const fixed = (key: string) => locked || whole.has(moduleOf.get(key.split(".")[0]!) ?? "") || byPattern.has(key) || (!picked.has(key) && !holds(key));
93
+ const inWhole = (key: string) => !isOwn(key) && whole.has(moduleOf.get(key.split(".")[0]!) ?? "");
94
+ const on = (key: string) => everything || inWhole(key) || byPattern.has(key) || picked.has(key);
95
+ const fixed = (key: string) => locked || inWhole(key) || byPattern.has(key) || (!picked.has(key) && !holds(key));
89
96
 
90
97
  const total = catalog.total;
91
98
  const count = useMemo(() => {
@@ -116,7 +123,7 @@ export function RoleForm({
116
123
  // A permission already covered by a whole section doesn't need saying twice.
117
124
  const grants = everything
118
125
  ? ["*"]
119
- : [...[...whole].map((section) => `@${section}.*`), ...kept, ...[...picked].filter((key) => !whole.has(moduleOf.get(key.split(".")[0]!) ?? ""))];
126
+ : [...[...whole].map((section) => `@${section}.*`), ...kept, ...[...picked].filter((key) => !inWhole(key))];
120
127
  const input = { name: name.trim(), description: description.trim() || null, grants };
121
128
 
122
129
  startTransition(async () => {
@@ -349,8 +356,11 @@ function FeatureRows({
349
356
  const keys = feature.actions.map((action) => `${feature.key}.${action}`);
350
357
  const every = keys.every(on);
351
358
  const some = keys.some(on);
359
+ // The resource's own actions: accept, send an announcement. They have no column, so they get a line.
360
+ const own = feature.actions.filter((action) => !(ACTIONS as readonly string[]).includes(action));
352
361
  return (
353
- <tr key={feature.key} className="border-b last:border-b-0">
362
+ <Fragment key={feature.key}>
363
+ <tr className={own.length > 0 ? undefined : "border-b last:border-b-0"}>
354
364
  <th scope="row" className="px-3 py-2 text-left font-normal">
355
365
  {feature.name}
356
366
  </th>
@@ -382,6 +392,31 @@ function FeatureRows({
382
392
  />
383
393
  </td>
384
394
  </tr>
395
+ {own.length > 0 && (
396
+ <tr className="border-b last:border-b-0">
397
+ <td colSpan={ACTIONS.length + 2} className="px-3 pt-0 pb-2">
398
+ <span className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
399
+ <span className="text-xs">Also</span>
400
+ {own.map((action) => {
401
+ const key = `${feature.key}.${action}`;
402
+ return (
403
+ <label key={action} className="flex items-center gap-2">
404
+ {/* Named with its resource, as the four are: two resources can both have an "Archive". */}
405
+ <Checkbox
406
+ aria-label={`${feature.labels?.[action] ?? action} ${feature.name.toLowerCase()}`}
407
+ checked={on(key)}
408
+ disabled={fixed(key)}
409
+ onCheckedChange={(value) => set([key], value === true)}
410
+ />
411
+ {feature.labels?.[action] ?? action}
412
+ </label>
413
+ );
414
+ })}
415
+ </span>
416
+ </td>
417
+ </tr>
418
+ )}
419
+ </Fragment>
385
420
  );
386
421
  })}
387
422
  </>
@@ -12,6 +12,7 @@ import { Checkbox } from "@/components/ui/checkbox";
12
12
  import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
13
13
  import { Input } from "@/components/ui/input";
14
14
  import { Spinner } from "@/components/ui/spinner";
15
+ import { isRich } from "@/lib/rich";
15
16
  import { cn } from "@/lib/utils";
16
17
  import { FieldWidget, type RelationMeta } from "./fields/field-widget";
17
18
 
@@ -41,6 +42,8 @@ const MOST_ROWS = 500;
41
42
 
42
43
  /** Kinds that fit in a cell: one control, one value. A file needs its own upload, so it is left to the form. */
43
44
  const IN_A_CELL = new Set(["string", "text", "int", "float", "boolean", "date", "datetime", "enum", "belongsTo"]);
45
+ /** A file needs an upload and rich text needs an editor: neither is something to type into a cell. */
46
+ const fitsACell = (def: { kind: string }) => IN_A_CELL.has(def.kind) && !isRich(def);
44
47
 
45
48
  /**
46
49
  * Add several records at once, in a grid.
@@ -82,10 +85,10 @@ function Grid({ resource, relations, onDone }: { resource: ClientResource; relat
82
85
  const [pending, startTransition] = useTransition();
83
86
  const validators = useMemo(() => createValidators(resource), [resource]);
84
87
  const all = useMemo(() => storedFields(resource) as [string, FieldDef][], [resource]);
85
- const fields = useMemo(() => all.filter(([, def]) => IN_A_CELL.has(def.kind)), [all]);
88
+ const fields = useMemo(() => all.filter(([, def]) => fitsACell(def)), [all]);
86
89
  // A field this grid can't show and Laravel requires would fail every row, with nothing
87
90
  // on screen to fix. Say so, and leave those records to the form.
88
- const missing = useMemo(() => all.filter(([, def]) => !IN_A_CELL.has(def.kind) && def.required !== false), [all]);
91
+ const missing = useMemo(() => all.filter(([, def]) => !fitsACell(def) && def.required !== false), [all]);
89
92
 
90
93
  const blank = useMemo(() => initialFormValues(resource, null) as Values, [resource]);
91
94
  const nextKey = useRef(STARTING_ROWS);
@@ -10,12 +10,14 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa
10
10
  import { Switch } from "@/components/ui/switch";
11
11
  import { Textarea } from "@/components/ui/textarea";
12
12
  import { groupDigits, ungroup } from "@/lib/number";
13
+ import { isRich } from "@/lib/rich";
13
14
  import { DateField, DateTimeField } from "./date-field";
14
15
  import { CountryField } from "./country-field";
15
16
  import { FileField } from "./file-field";
16
17
  import { MultiSelectField } from "./multi-select-field";
17
18
  import { PhoneField } from "./phone-field";
18
19
  import { RelationField } from "./relation-field";
20
+ import { RichTextField } from "./rich-text-field";
19
21
 
20
22
  export interface RelationMeta {
21
23
  name: string;
@@ -56,6 +58,9 @@ export function FieldWidget(props: WidgetProps) {
56
58
 
57
59
  switch (field.kind) {
58
60
  case "text":
61
+ if (isRich(field)) {
62
+ return <RichTextField id={id} value={text} onChange={onChange} invalid={invalid} disabled={disabled} placeholder={field.placeholder} />;
63
+ }
59
64
  return <Textarea {...common} value={text} rows={4} onChange={(event) => onChange(event.target.value)} />;
60
65
 
61
66
  case "int":
@@ -1,5 +1,5 @@
1
- import { FileIcon } from "lucide-react";
2
- import { fileName, fileUrl, isImageKey } from "@/lib/files";
1
+ import { DownloadIcon, FileIcon, LockIcon } from "lucide-react";
2
+ import { fileName, fileUrl, isImageKey, isPrivateKey } from "@/lib/files";
3
3
 
4
4
  /**
5
5
  * A stored file in a table cell or a record's detail.
@@ -8,8 +8,29 @@ import { fileName, fileUrl, isImageKey } from "@/lib/files";
8
8
  * uploaded, so a table of fifty products downloads fifty small pictures, not fifty
9
9
  * full-size ones. Anything else is its filename, which is the useful part of a key
10
10
  * nobody wants to read in full.
11
+ *
12
+ * A private file is its name and a lock, picture or not: it has no thumbnail and no
13
+ * address of its own. With `download`, where it is fetched through its record, the name
14
+ * is the link that saves it.
11
15
  */
12
- export function FileCell({ value, size = 36 }: { resourceName?: string; fieldKey?: string; value: string; size?: number }) {
16
+ export function FileCell({ value, size = 36, download }: { resourceName?: string; fieldKey?: string; value: string; size?: number; download?: string }) {
17
+ if (isPrivateKey(value)) {
18
+ const name = <span className="max-w-[24ch] truncate">{fileName(value)}</span>;
19
+ return (
20
+ <span className="inline-flex items-center gap-1.5 text-muted-foreground">
21
+ <LockIcon aria-label="Private" className="size-3.5" />
22
+ {download ? (
23
+ <a href={download} download className="inline-flex items-center gap-1.5 text-primary hover:underline">
24
+ {name}
25
+ <DownloadIcon aria-hidden="true" className="size-3.5" />
26
+ </a>
27
+ ) : (
28
+ name
29
+ )}
30
+ </span>
31
+ );
32
+ }
33
+
13
34
  if (!isImageKey(value)) {
14
35
  return (
15
36
  <span className="inline-flex items-center gap-1.5 text-muted-foreground">
@@ -14,7 +14,7 @@ import {
14
14
  import { createUploadUrlAction } from "@/app/dashboard/actions";
15
15
  import { Button } from "@/components/ui/button";
16
16
  import { Progress } from "@/components/ui/progress";
17
- import { fileName, fileUrl, isImageKey } from "@/lib/files";
17
+ import { fileName, fileUrl, isImageKey, isPrivateKey } from "@/lib/files";
18
18
  import { cn } from "@/lib/utils";
19
19
 
20
20
  interface Props {
@@ -107,7 +107,8 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
107
107
 
108
108
  // Thumbnail for a stored image (a local preview replaces it while uploading).
109
109
  useEffect(() => {
110
- setPreview(value && isImageKey(value) ? fileUrl(value, "thumb") : null);
110
+ // A private file has no thumbnail, and nothing to fetch one from.
111
+ setPreview(value && isImageKey(value) && !isPrivateKey(value) ? fileUrl(value, "thumb") : null);
111
112
  }, [value]);
112
113
 
113
114
  async function check(file: File): Promise<string | null> {
@@ -236,9 +237,12 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
236
237
  {thumbnail}
237
238
  <span className="min-w-0 flex-1 truncate text-sm font-medium">{fileName(value)}</span>
238
239
  <div className="flex shrink-0 gap-1">
239
- <Button type="button" variant="ghost" size="sm" onClick={open}>
240
- Open<span className="sr-only"> {fileName(value)}</span>
241
- </Button>
240
+ {/* A private file is fetched through its record, from the record's page, not from here. */}
241
+ {!isPrivateKey(value) && (
242
+ <Button type="button" variant="ghost" size="sm" onClick={open}>
243
+ Open<span className="sr-only"> {fileName(value)}</span>
244
+ </Button>
245
+ )}
242
246
  {/* The field label points here; aria-label keeps the button named for what it does. */}
243
247
  <Button
244
248
  id={id}
@@ -0,0 +1,261 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { BoldIcon, Heading2Icon, Heading3Icon, ItalicIcon, LinkIcon, ListIcon, ListOrderedIcon, Redo2Icon, StrikethroughIcon, TextQuoteIcon, Undo2Icon } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
8
+ import { isBlankHtml, linkAddress, tidyHtml } from "@/lib/rich";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ type Mark = "bold" | "italic" | "strike" | "link" | "h2" | "h3" | "bullets" | "numbers" | "quote";
12
+
13
+ /**
14
+ * The editor for a rich text field: a page to type on, and a toolbar of the eleven
15
+ * things that can be done to it.
16
+ *
17
+ * It is the browser's own editing (`contentEditable` and `document.execCommand`), with
18
+ * no editor library behind it, so it adds nothing to install or to keep up to date.
19
+ * Browsers disagree on the HTML they write (`<b>` or `<strong>`, `<div>` or `<p>`);
20
+ * Laravel settles that when it cleans what is saved, and sends back the one spelling.
21
+ */
22
+ export function RichTextField({
23
+ id,
24
+ value,
25
+ onChange,
26
+ invalid,
27
+ disabled,
28
+ placeholder,
29
+ }: {
30
+ id: string;
31
+ value: string;
32
+ onChange: (value: string) => void;
33
+ invalid?: boolean;
34
+ disabled?: boolean;
35
+ placeholder?: string;
36
+ }) {
37
+ const page = useRef<HTMLDivElement>(null);
38
+ /** What the page last said it holds. A value that differs came from outside: a record loading, a form resetting. */
39
+ const said = useRef<string | null>(null);
40
+ /** Where the cursor was when the link box opened, since typing an address moves it away. */
41
+ const place = useRef<Range | null>(null);
42
+ const [marks, setMarks] = useState<Partial<Record<Mark, boolean>>>({});
43
+ const [linking, setLinking] = useState(false);
44
+ const [address, setAddress] = useState("");
45
+
46
+ useEffect(() => {
47
+ const element = page.current;
48
+ if (element && value !== said.current) {
49
+ element.innerHTML = value;
50
+ said.current = value;
51
+ }
52
+ }, [value]);
53
+
54
+ /** The nearest tag of this name around the cursor, inside the page. */
55
+ const around = useCallback((tag: string): Element | null => {
56
+ const selection = window.getSelection();
57
+ const node = selection && selection.rangeCount > 0 ? selection.anchorNode : null;
58
+ const element = node instanceof Element ? node : (node?.parentElement ?? null);
59
+ const found = element?.closest(tag) ?? null;
60
+ return found && page.current?.contains(found) ? found : null;
61
+ }, []);
62
+
63
+ const look = useCallback(() => {
64
+ if (!page.current || !page.current.contains(window.getSelection()?.anchorNode ?? null)) return;
65
+ setMarks({
66
+ bold: document.queryCommandState("bold"),
67
+ italic: document.queryCommandState("italic"),
68
+ strike: document.queryCommandState("strikeThrough"),
69
+ bullets: document.queryCommandState("insertUnorderedList"),
70
+ numbers: document.queryCommandState("insertOrderedList"),
71
+ link: around("a") !== null,
72
+ h2: around("h2") !== null,
73
+ h3: around("h3") !== null,
74
+ quote: around("blockquote") !== null,
75
+ });
76
+ }, [around]);
77
+
78
+ useEffect(() => {
79
+ document.addEventListener("selectionchange", look);
80
+ return () => document.removeEventListener("selectionchange", look);
81
+ }, [look]);
82
+
83
+ const say = useCallback(() => {
84
+ const element = page.current;
85
+ if (!element) return;
86
+ // Emptied, a page still holds the paragraph the cursor sits in.
87
+ const html = isBlankHtml(element.innerHTML) ? "" : element.innerHTML;
88
+ said.current = html;
89
+ onChange(html);
90
+ }, [onChange]);
91
+
92
+ const run = useCallback(
93
+ (command: string, argument?: string) => {
94
+ page.current?.focus();
95
+ document.execCommand(command, false, argument);
96
+ say();
97
+ look();
98
+ },
99
+ [say, look],
100
+ );
101
+
102
+ /** A heading is a kind of line: pressed again, the line goes back to being a paragraph. */
103
+ const heading = (tag: "h2" | "h3") => run("formatBlock", around(tag) ? "p" : tag);
104
+ const quote = () => (around("blockquote") ? run("outdent") : run("formatBlock", "blockquote"));
105
+
106
+ function openLink(open: boolean) {
107
+ if (open) {
108
+ const selection = window.getSelection();
109
+ place.current = selection && selection.rangeCount > 0 && page.current?.contains(selection.anchorNode) ? selection.getRangeAt(0).cloneRange() : null;
110
+ setAddress(around("a")?.getAttribute("href") ?? "");
111
+ }
112
+ setLinking(open);
113
+ }
114
+
115
+ function applyLink(typed: string) {
116
+ setLinking(false);
117
+ const href = linkAddress(typed);
118
+ // After the box has closed and handed the cursor back.
119
+ window.setTimeout(() => {
120
+ const element = page.current;
121
+ if (!element) return;
122
+ element.focus();
123
+ const selection = window.getSelection();
124
+ if (place.current && selection) {
125
+ selection.removeAllRanges();
126
+ selection.addRange(place.current);
127
+ }
128
+ if (href === "") {
129
+ run("unlink");
130
+ } else if (selection?.isCollapsed && !around("a")) {
131
+ // Nothing selected to make a link of: the address is its own words.
132
+ const text = href.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
133
+ run("insertHTML", `<a href="${text}">${text}</a>`);
134
+ } else {
135
+ run("createLink", href);
136
+ }
137
+ }, 0);
138
+ }
139
+
140
+ const tools: { mark?: Mark; label: string; icon: React.ReactNode; press: () => void }[][] = [
141
+ [
142
+ { mark: "bold", label: "Bold", icon: <BoldIcon />, press: () => run("bold") },
143
+ { mark: "italic", label: "Italic", icon: <ItalicIcon />, press: () => run("italic") },
144
+ { mark: "strike", label: "Strikethrough", icon: <StrikethroughIcon />, press: () => run("strikeThrough") },
145
+ ],
146
+ [
147
+ { mark: "h2", label: "Heading", icon: <Heading2Icon />, press: () => heading("h2") },
148
+ { mark: "h3", label: "Smaller heading", icon: <Heading3Icon />, press: () => heading("h3") },
149
+ { mark: "bullets", label: "Bullet list", icon: <ListIcon />, press: () => run("insertUnorderedList") },
150
+ { mark: "numbers", label: "Numbered list", icon: <ListOrderedIcon />, press: () => run("insertOrderedList") },
151
+ { mark: "quote", label: "Quote", icon: <TextQuoteIcon />, press: quote },
152
+ ],
153
+ ];
154
+ const history = [
155
+ { label: "Undo", icon: <Undo2Icon />, press: () => run("undo") },
156
+ { label: "Redo", icon: <Redo2Icon />, press: () => run("redo") },
157
+ ];
158
+
159
+ const tool = (item: { mark?: Mark; label: string; icon: React.ReactNode; press: () => void }) => (
160
+ <Button
161
+ key={item.label}
162
+ type="button"
163
+ variant="ghost"
164
+ size="icon-sm"
165
+ title={item.label}
166
+ aria-label={item.label}
167
+ aria-pressed={item.mark ? Boolean(marks[item.mark]) : undefined}
168
+ disabled={disabled}
169
+ className={cn("size-7", item.mark && marks[item.mark] && "bg-accent text-accent-foreground")}
170
+ // Pressing a tool must not take the cursor out of the page.
171
+ onMouseDown={(event) => event.preventDefault()}
172
+ onClick={item.press}
173
+ >
174
+ {item.icon}
175
+ </Button>
176
+ );
177
+
178
+ return (
179
+ <div
180
+ className={cn(
181
+ "rounded-md border border-input shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30",
182
+ invalid && "border-destructive ring-destructive/20 dark:ring-destructive/40",
183
+ disabled && "cursor-not-allowed opacity-50",
184
+ )}
185
+ >
186
+ <div role="toolbar" aria-label="Formatting" className="flex flex-wrap items-center gap-0.5 border-b border-input px-1.5 py-1">
187
+ {tools[0].map(tool)}
188
+ <Popover open={linking} onOpenChange={openLink}>
189
+ <PopoverTrigger asChild>
190
+ <Button
191
+ type="button"
192
+ variant="ghost"
193
+ size="icon-sm"
194
+ title="Link"
195
+ aria-label="Link"
196
+ aria-pressed={Boolean(marks.link)}
197
+ disabled={disabled}
198
+ className={cn("size-7", marks.link && "bg-accent text-accent-foreground")}
199
+ onMouseDown={(event) => event.preventDefault()}
200
+ >
201
+ <LinkIcon />
202
+ </Button>
203
+ </PopoverTrigger>
204
+ <PopoverContent align="start" className="w-80">
205
+ {/* Not a form: this sits inside the record's own form, and a form can't hold another. */}
206
+ <div className="flex flex-col gap-2">
207
+ <Input
208
+ autoFocus
209
+ value={address}
210
+ placeholder="https://example.com"
211
+ aria-label="Link address"
212
+ onChange={(event) => setAddress(event.target.value)}
213
+ onKeyDown={(event) => {
214
+ if (event.key === "Enter") {
215
+ event.preventDefault();
216
+ applyLink(address);
217
+ }
218
+ }}
219
+ />
220
+ <div className="flex justify-end gap-2">
221
+ {marks.link && (
222
+ <Button type="button" variant="ghost" size="sm" onClick={() => applyLink("")}>
223
+ Remove link
224
+ </Button>
225
+ )}
226
+ <Button type="button" size="sm" onClick={() => applyLink(address)}>
227
+ Apply
228
+ </Button>
229
+ </div>
230
+ </div>
231
+ </PopoverContent>
232
+ </Popover>
233
+ <span aria-hidden className="mx-1 h-5 w-px bg-border" />
234
+ {tools[1].map(tool)}
235
+ <span aria-hidden className="mx-1 h-5 w-px bg-border" />
236
+ {history.map(tool)}
237
+ </div>
238
+ <div
239
+ ref={page}
240
+ id={id}
241
+ role="textbox"
242
+ aria-multiline="true"
243
+ aria-invalid={invalid || undefined}
244
+ aria-placeholder={placeholder}
245
+ data-placeholder={placeholder}
246
+ contentEditable={!disabled}
247
+ suppressContentEditableWarning
248
+ className="rich-text max-h-[32rem] min-h-40 overflow-y-auto px-3 py-2 text-base outline-none empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] md:text-sm"
249
+ onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")}
250
+ onInput={say}
251
+ onBlur={say}
252
+ onPaste={(event) => {
253
+ event.preventDefault();
254
+ const html = event.clipboardData.getData("text/html");
255
+ if (html) run("insertHTML", tidyHtml(html));
256
+ else run("insertText", event.clipboardData.getData("text/plain"));
257
+ }}
258
+ />
259
+ </div>
260
+ );
261
+ }
@@ -19,7 +19,9 @@ import {
19
19
  } from "@/components/ui/alert-dialog";
20
20
  import { Button } from "@/components/ui/button";
21
21
  import { Spinner } from "@/components/ui/spinner";
22
+ import type { ResourceAction } from "@/lib/extras";
22
23
  import { keptFor, type TrashInfo } from "@/lib/trash-info";
24
+ import { ActionButtons } from "./resource-action";
23
25
  import { ResourceFormSheet, type FormRelations } from "./resource-form-sheet";
24
26
  import { toastMovedToTrash } from "./trash-actions";
25
27
 
@@ -36,10 +38,12 @@ interface Props {
36
38
  overlayForms: boolean;
37
39
  /** This resource's trash, when a deleted record goes there instead of going for good. */
38
40
  trash?: TrashInfo;
41
+ /** The resource's own actions that this person may run and that apply to this record as it is. */
42
+ actions?: ResourceAction[];
39
43
  }
40
44
 
41
- /** Edit and delete, at the top of a record's own page. Deleting goes back to the list. */
42
- export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms, trash }: Props) {
45
+ /** The resource's own actions, then edit and delete, at the top of a record's own page. Deleting goes back to the list. */
46
+ export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms, trash, actions = [] }: Props) {
43
47
  const router = useRouter();
44
48
  const [editing, setEditing] = useState(false);
45
49
  const [confirming, setConfirming] = useState(false);
@@ -63,6 +67,7 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
63
67
 
64
68
  return (
65
69
  <>
70
+ <ActionButtons resourceName={resource.name} actions={actions} id={id} />
66
71
  {canUpdate &&
67
72
  (overlayForms ? (
68
73
  <Button variant="outline" onClick={() => setEditing(true)}>
@@ -5,7 +5,10 @@ import { Badge } from "@/components/ui/badge";
5
5
  import { FileCell } from "@/components/dashboard/fields/file-cell";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
8
- import { adminPermissions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
8
+ import { adminPermissions, allowedActions, allResources, dashboardStore, requireAccess, resourcePath } from "@/lib/dashboard";
9
+ import { actionApplies } from "@/lib/extras";
10
+ import { privateFileUrl } from "@/lib/files";
11
+ import { isRich } from "@/lib/rich";
9
12
  import { site } from "@/lib/site";
10
13
  import { trashFor } from "@/lib/trash";
11
14
  import { LocalTime } from "./local-time";
@@ -93,6 +96,7 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
93
96
  canDelete={permissions.delete}
94
97
  overlayForms={site.dashboard.forms === "sheet"}
95
98
  trash={trash}
99
+ actions={(await allowedActions(resource.name)).filter((action) => action.on === "record" && actionApplies(action, record))}
96
100
  />
97
101
  }
98
102
  />
@@ -111,9 +115,12 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
111
115
  } else if (def.kind === "enum" && value != null) {
112
116
  const tone = statusTone(value);
113
117
  content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
118
+ } else if (isRich(def) && typeof value === "string" && value) {
119
+ // Shown as it was written. Laravel cleaned it when it was saved and again when it was read.
120
+ content = <div className="rich-text" dangerouslySetInnerHTML={{ __html: value }} />;
114
121
  } else if (def.kind === "file" && typeof value === "string" && value) {
115
122
  // Bigger here than in the table: this is the page for looking at one record.
116
- content = <FileCell resourceName={resource.name} fieldKey={key} value={value} size={96} />;
123
+ content = <FileCell resourceName={resource.name} fieldKey={key} value={value} size={96} download={privateFileUrl(resource.slug, id, key)} />;
117
124
  } else if (def.kind === "multiselect" && Array.isArray(value) && value.length > 0) {
118
125
  content = (
119
126
  <span className="flex flex-wrap gap-1">