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
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ import type { Resource } from "@flaredev/core";
2
+ import { ShieldCheckIcon } from "lucide-react";
3
+ import { Alert, AlertDescription } from "@/components/ui/alert";
4
+ import { formOf } from "@/lib/extras";
5
+ import { laravel } from "@/lib/laravel";
6
+
7
+ /** GET _nevela/forms/<slug>: what a resource's public form checks and has turned away. */
8
+ interface FormReport {
9
+ robotCheck: "on" | "off" | "skipped" | "refusing";
10
+ blocked: { total: number; days: number; reasons: { reason: string; label: string; count: number }[] };
11
+ }
12
+
13
+ /**
14
+ * What a resource's public form turned away this week, above its table: "Blocked this
15
+ * week: 132 (118 robot check, 14 throwaway address)". Without it a form that is well
16
+ * protected and a form nobody uses look the same from in here.
17
+ *
18
+ * Shown for a resource whose descriptor has "protect", and says so plainly when the
19
+ * robot check it asks for isn't being made.
20
+ */
21
+ export async function FormActivity({ resource }: { resource: Resource }) {
22
+ if (!formOf(resource.name)?.protect) return null;
23
+ const response = await laravel(`_nevela/forms/${resource.slug}`);
24
+ if (!response.ok) return null;
25
+ const { robotCheck, blocked } = response.body as FormReport;
26
+
27
+ return (
28
+ <div className="flex flex-col gap-2">
29
+ {robotCheck === "refusing" && (
30
+ <Alert variant="destructive">
31
+ <AlertDescription>
32
+ The public form is refusing everyone: it asks for the robot check, and TURNSTILE_SECRET_KEY isn&apos;t set in the API. Set it, or take
33
+ &quot;turnstile&quot; out of the descriptor.
34
+ </AlertDescription>
35
+ </Alert>
36
+ )}
37
+ {robotCheck === "skipped" && (
38
+ <Alert>
39
+ <AlertDescription>
40
+ The robot check is skipped while TURNSTILE_SECRET_KEY isn&apos;t set. That is fine in development. Set it before the site goes live.
41
+ </AlertDescription>
42
+ </Alert>
43
+ )}
44
+ <p className="flex items-center gap-2 text-sm text-muted-foreground">
45
+ <ShieldCheckIcon className="size-4 shrink-0" aria-hidden="true" />
46
+ <span>
47
+ Blocked this week: <span className="font-medium text-foreground tabular-nums">{blocked.total === 0 ? "none" : blocked.total.toLocaleString()}</span>
48
+ {blocked.total > 0 && ` (${blocked.reasons.map((row) => `${row.count.toLocaleString()} ${row.label}`).join(", ")})`}
49
+ </span>
50
+ </p>
51
+ </div>
52
+ );
53
+ }
@@ -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 { PencilIcon, Trash2Icon } from "lucide-react";
6
+ import { EyeOffIcon, 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,
@@ -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 { PublishState, 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,11 +38,25 @@ 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[];
43
+ /** For a resource with a publish date: what this record is now, so the right one of Publish and Unpublish is offered. */
44
+ published?: PublishState;
39
45
  }
40
46
 
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) {
47
+ /** Publishing, the resource's own actions, then edit and delete, at the top of a record's own page. Deleting goes back to the list. */
48
+ export function RecordActions({ resource, id, record, relations, listHref, editHref, canUpdate, canDelete, overlayForms, trash, actions = [], published }: Props) {
43
49
  const router = useRouter();
50
+ const [publishing, startPublishing] = useTransition();
51
+
52
+ const publish = (now: boolean) => {
53
+ startPublishing(async () => {
54
+ const result = await publishRecordAction(resource.name, id, now);
55
+ if (result.ok) toast.success(now ? `${resource.label} published.` : `${resource.label} is a draft again.`);
56
+ else toast.error(result.error);
57
+ router.refresh();
58
+ });
59
+ };
44
60
  const [editing, setEditing] = useState(false);
45
61
  const [confirming, setConfirming] = useState(false);
46
62
  const [pending, startTransition] = useTransition();
@@ -63,6 +79,19 @@ export function RecordActions({ resource, id, record, relations, listHref, editH
63
79
 
64
80
  return (
65
81
  <>
82
+ {canUpdate && published && published !== "published" && (
83
+ <Button disabled={publishing} onClick={() => publish(true)}>
84
+ {publishing ? <Spinner data-icon="inline-start" /> : <SendIcon data-icon="inline-start" />}
85
+ {published === "scheduled" ? "Publish now" : "Publish"}
86
+ </Button>
87
+ )}
88
+ {canUpdate && published === "published" && (
89
+ <Button variant="outline" disabled={publishing} onClick={() => publish(false)}>
90
+ {publishing ? <Spinner data-icon="inline-start" /> : <EyeOffIcon data-icon="inline-start" />}
91
+ Unpublish
92
+ </Button>
93
+ )}
94
+ <ActionButtons resourceName={resource.name} actions={actions} id={id} />
66
95
  {canUpdate &&
67
96
  (overlayForms ? (
68
97
  <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, publishFieldOf, publishState } 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,8 @@ 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))}
100
+ published={publishFieldOf(resource.name) ? publishState(record[publishFieldOf(resource.name)!]) : undefined}
96
101
  />
97
102
  }
98
103
  />
@@ -111,9 +116,12 @@ export async function RecordDetail({ resource, id }: { resource: Resource; id: s
111
116
  } else if (def.kind === "enum" && value != null) {
112
117
  const tone = statusTone(value);
113
118
  content = <Badge variant={tone === "neutral" ? "secondary" : tone}>{content}</Badge>;
119
+ } else if (isRich(def) && typeof value === "string" && value) {
120
+ // Shown as it was written. Laravel cleaned it when it was saved and again when it was read.
121
+ content = <div className="rich-text" dangerouslySetInnerHTML={{ __html: value }} />;
114
122
  } else if (def.kind === "file" && typeof value === "string" && value) {
115
123
  // 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} />;
124
+ content = <FileCell resourceName={resource.name} fieldKey={key} value={value} size={96} download={privateFileUrl(resource.slug, id, key)} />;
117
125
  } else if (def.kind === "multiselect" && Array.isArray(value) && value.length > 0) {
118
126
  content = (
119
127
  <span className="flex flex-wrap gap-1">