create-nevela 0.8.0 → 0.9.2

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 (62) hide show
  1. package/index.mjs +24 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +41 -0
  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/Concerns/Confirmable.php +81 -0
  9. package/template/laravel/src/Console/BlogCommand.php +254 -0
  10. package/template/laravel/src/Console/DevCommand.php +9 -0
  11. package/template/laravel/src/Console/DigestCommand.php +28 -0
  12. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  13. package/template/laravel/src/Console/GenerateCommand.php +25 -0
  14. package/template/laravel/src/Console/SetupCommand.php +15 -2
  15. package/template/laravel/src/Console/StatusCommand.php +14 -0
  16. package/template/laravel/src/Console/TrashCommand.php +9 -0
  17. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  18. package/template/laravel/src/Forms/Confirmation.php +88 -0
  19. package/template/laravel/src/Forms/Disposable.php +74 -0
  20. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  21. package/template/laravel/src/Forms/FormGuard.php +359 -0
  22. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  23. package/template/laravel/src/Forms/PublicMail.php +217 -0
  24. package/template/laravel/src/Forms/Refusal.php +50 -0
  25. package/template/laravel/src/Generator/BlogPages.php +409 -0
  26. package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
  27. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  28. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  29. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  30. package/template/laravel/src/Http/ActionController.php +2 -1
  31. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  32. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  33. package/template/laravel/src/Http/FormsController.php +45 -0
  34. package/template/laravel/src/Http/PublicController.php +89 -4
  35. package/template/laravel/src/Http/ResourceRequest.php +43 -3
  36. package/template/laravel/src/Http/TrashController.php +2 -1
  37. package/template/laravel/src/Http/UploadController.php +2 -2
  38. package/template/laravel/src/Nevela.php +30 -1
  39. package/template/laravel/src/NevelaServiceProvider.php +11 -2
  40. package/template/laravel/src/Support/Descriptor.php +24 -0
  41. package/template/laravel/src/Support/Field.php +17 -0
  42. package/template/laravel/src/Support/Key.php +57 -0
  43. package/template/laravel/src/Support/ListQuery.php +29 -1
  44. package/template/laravel/src/Support/PublicAccess.php +75 -0
  45. package/template/laravel/src/Support/Slug.php +43 -0
  46. package/template/laravel/src/Trash/Trash.php +3 -1
  47. package/template/web/.env.example +4 -0
  48. package/template/web/app/api/[...path]/route.ts +6 -3
  49. package/template/web/app/confirm/page.tsx +51 -0
  50. package/template/web/app/dashboard/actions.ts +15 -1
  51. package/template/web/components/dashboard/form-activity.tsx +53 -0
  52. package/template/web/components/dashboard/record-actions.tsx +29 -5
  53. package/template/web/components/dashboard/record-detail.tsx +2 -1
  54. package/template/web/components/dashboard/resource-form.tsx +19 -2
  55. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  56. package/template/web/components/dashboard/resource-table.tsx +32 -3
  57. package/template/web/components/dashboard/row-actions.tsx +33 -3
  58. package/template/web/components/marketing/site-header.tsx +9 -2
  59. package/template/web/components/public-form-guard.tsx +125 -0
  60. package/template/web/hooks/use-public-form.ts +263 -0
  61. package/template/web/lib/extras.ts +39 -0
  62. package/template/web/package.json +1 -1
@@ -0,0 +1,263 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ /** What Laravel hands a protected form before it is shown: GET /api/public/_nevela/form/<slug>. */
6
+ interface Issued {
7
+ token: string | null;
8
+ honeypot: string | null;
9
+ minSeconds: number;
10
+ turnstile: boolean;
11
+ /** The name the robot check is made under. Cloudflare returns it, and Laravel checks it is this form's. */
12
+ action?: string | null;
13
+ }
14
+
15
+ /** What Laravel says when it refuses something. */
16
+ interface Refused {
17
+ error?: string;
18
+ issues?: { path?: string; message?: string }[];
19
+ }
20
+
21
+ export interface PublicForm {
22
+ /** Give this to the form: `<form onSubmit={form.submit}>`. */
23
+ submit: (event: React.FormEvent<HTMLFormElement>) => void;
24
+ /** Being sent: its files first, then the form. */
25
+ pending: boolean;
26
+ /** The file field being uploaded right now, or null. */
27
+ uploading: string | null;
28
+ /** Sent, and taken. */
29
+ sent: boolean;
30
+ /** What went wrong, where it isn't about one field: the robot check, too many tries, no connection. */
31
+ error: string | null;
32
+ /** What is wrong with a field, by its name. */
33
+ errors: Record<string, string>;
34
+ /** Start again after a form was sent, to send another. */
35
+ again: () => void;
36
+ /**
37
+ * Upload one file for a file field of this form, and get the key to send as its value.
38
+ * `submit` does this for every file input in the form. It is here for a form that
39
+ * builds its own values, or uploads as soon as a file is chosen.
40
+ */
41
+ upload: (field: string, file: File) => Promise<{ key: string } | { error: string }>;
42
+ /** For `<PublicFormGuard form={form} />`. Not for the form itself to read. */
43
+ guard: {
44
+ honeypot: string | null;
45
+ turnstile: boolean;
46
+ action: string | null;
47
+ /** Goes up after every attempt: a robot token is good once, so the widget starts over. */
48
+ attempt: number;
49
+ robot: (token: string | null) => void;
50
+ };
51
+ }
52
+
53
+ export interface PublicFormOptions {
54
+ /**
55
+ * The record to send, from the form's fields. By default each named field, as typed,
56
+ * with empty ones left out, and for each file input the key of its uploaded file.
57
+ * `uploaded` holds those keys by field name.
58
+ */
59
+ values?: (data: FormData, uploaded: Record<string, string>) => Record<string, unknown>;
60
+ onSent?: () => void;
61
+ }
62
+
63
+ const UNREACHABLE = "That didn't send. Check your connection and try again.";
64
+
65
+ /** Whether two files are the same choice: an attempt that failed elsewhere doesn't upload it again. */
66
+ const sameFile = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
67
+
68
+ /**
69
+ * A form anyone can send: a resource's public create, with whatever its descriptor's
70
+ * "protect" asks for.
71
+ *
72
+ * ```tsx
73
+ * const form = usePublicForm("internship-applications");
74
+ *
75
+ * <form onSubmit={form.submit}>
76
+ * <input name="email" />
77
+ * {form.errors.email}
78
+ * <input name="cv" type="file" accept="application/pdf" />
79
+ * {form.errors.cv}
80
+ * <PublicFormGuard form={form} />
81
+ * <button disabled={form.pending}>{form.uploading ? "Uploading…" : "Send"}</button>
82
+ * {form.error}
83
+ * </form>
84
+ * ```
85
+ *
86
+ * It asks Laravel for the form's token when the form is shown and again after every
87
+ * attempt, sends the fields with it, and sorts the answer: a problem with a field goes to
88
+ * `errors` under that field's name, and anything else to `error`. The checks themselves
89
+ * are Laravel's. Nothing here decides who is let through.
90
+ *
91
+ * A file input is sent in two steps, as Laravel wants it: the file first, to the form's
92
+ * own upload address, and then its key as the field's value. The field's types and size
93
+ * limit are Laravel's to enforce, and what it refuses is shown under the field. A file
94
+ * that was uploaded is not uploaded again when the form fails for another reason.
95
+ *
96
+ * A resource with no "protect" works too: there is no token to fetch, and the form is
97
+ * sent as it is.
98
+ */
99
+ export function usePublicForm(slug: string, { values, onSent }: PublicFormOptions = {}): PublicForm {
100
+ const [issued, setIssued] = React.useState<Issued | null>(null);
101
+ const [attempt, setAttempt] = React.useState(0);
102
+ const [pending, setPending] = React.useState(false);
103
+ const [uploading, setUploading] = React.useState<string | null>(null);
104
+ const [sent, setSent] = React.useState(false);
105
+ const [error, setError] = React.useState<string | null>(null);
106
+ const [errors, setErrors] = React.useState<Record<string, string>>({});
107
+ const robot = React.useRef<string | null>(null);
108
+ const sending = React.useRef(false);
109
+ /** Files already uploaded for this form and not yet used, by field. */
110
+ const uploaded = React.useRef<Record<string, { file: File; key: string }>>({});
111
+
112
+ // The token says when the form was handed out, so it is asked for when the form
113
+ // appears, and a fresh one after each attempt.
114
+ React.useEffect(() => {
115
+ let current = true;
116
+ fetch(`/api/public/_nevela/form/${encodeURIComponent(slug)}`, { cache: "no-store", headers: { Accept: "application/json" } })
117
+ .then((response) => (response.ok ? (response.json() as Promise<Issued>) : null))
118
+ .then((form) => {
119
+ if (current) setIssued(form);
120
+ })
121
+ .catch(() => {
122
+ if (current) setIssued(null);
123
+ });
124
+ return () => {
125
+ current = false;
126
+ };
127
+ }, [slug, attempt]);
128
+
129
+ const setRobot = React.useCallback((token: string | null) => {
130
+ robot.current = token;
131
+ }, []);
132
+
133
+ const upload = React.useCallback(
134
+ async (field: string, file: File): Promise<{ key: string } | { error: string }> => {
135
+ try {
136
+ const response = await fetch(`/api/public/${encodeURIComponent(slug)}/_uploads/${encodeURIComponent(field)}?name=${encodeURIComponent(file.name)}`, {
137
+ method: "PUT",
138
+ headers: { "Content-Type": file.type || "application/octet-stream", Accept: "application/json" },
139
+ body: file,
140
+ });
141
+ const answer = (await response.json().catch(() => null)) as (Refused & { key?: string }) | null;
142
+ if (response.ok && typeof answer?.key === "string") return { key: answer.key };
143
+ return { error: answer?.issues?.find((issue) => issue.message)?.message ?? answer?.error ?? "That file couldn't be uploaded." };
144
+ } catch {
145
+ return { error: UNREACHABLE };
146
+ }
147
+ },
148
+ [slug],
149
+ );
150
+
151
+ const submit = React.useCallback(
152
+ (event: React.FormEvent<HTMLFormElement>) => {
153
+ event.preventDefault();
154
+ if (sending.current) return;
155
+ const element = event.currentTarget;
156
+ const data = new FormData(element);
157
+
158
+ sending.current = true;
159
+ setPending(true);
160
+ setError(null);
161
+ setErrors({});
162
+
163
+ void (async () => {
164
+ // The files first: each to its own field's address, for a key to send in its place.
165
+ const keys: Record<string, string> = {};
166
+ for (const [name, value] of data.entries()) {
167
+ // An input nobody chose a file for is an empty file with no name.
168
+ if (typeof value === "string" || value.size === 0) continue;
169
+ const before = uploaded.current[name];
170
+ if (before && sameFile(before.file, value)) {
171
+ keys[name] = before.key;
172
+ continue;
173
+ }
174
+ setUploading(name);
175
+ const result = await upload(name, value);
176
+ setUploading(null);
177
+ if ("error" in result) {
178
+ // Nothing was sent, so the form's token and the robot check are still good.
179
+ setErrors({ [name]: result.error });
180
+ return false;
181
+ }
182
+ uploaded.current[name] = { file: value, key: result.key };
183
+ keys[name] = result.key;
184
+ }
185
+
186
+ const body: Record<string, unknown> = values ? { ...values(data, keys) } : { ...keys };
187
+ if (!values) {
188
+ for (const [name, value] of data.entries()) {
189
+ if (typeof value === "string" && value !== "") body[name] = value;
190
+ }
191
+ }
192
+ // The field no person sees goes as it is, whatever `values` kept: it is how a bot is known.
193
+ if (issued?.honeypot) {
194
+ const bait = data.get(issued.honeypot);
195
+ if (typeof bait === "string" && bait !== "") body[issued.honeypot] = bait;
196
+ }
197
+ if (issued?.token) body._form = issued.token;
198
+ if (robot.current) body._turnstile = robot.current;
199
+
200
+ try {
201
+ const response = await fetch(`/api/public/${encodeURIComponent(slug)}`, {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
204
+ body: JSON.stringify(body),
205
+ });
206
+ if (response.ok) {
207
+ // Its files belong to the record now.
208
+ uploaded.current = {};
209
+ element.reset();
210
+ setSent(true);
211
+ onSent?.();
212
+ return true;
213
+ }
214
+ const answer = (await response.json().catch(() => null)) as Refused | null;
215
+ const byField: Record<string, string> = {};
216
+ const rest: string[] = [];
217
+ for (const issue of answer?.issues ?? []) {
218
+ if (!issue.message) continue;
219
+ // The first thing wrong with a field is the one to fix first.
220
+ if (issue.path && element.elements.namedItem(issue.path)) byField[issue.path] ??= issue.message;
221
+ else rest.push(issue.path ? `${issue.path}: ${issue.message}` : issue.message);
222
+ }
223
+ // A file Laravel won't take as it is (used, or uploaded too long ago) is uploaded again next time.
224
+ for (const name of Object.keys(byField)) delete uploaded.current[name];
225
+ setErrors(byField);
226
+ setError(Object.keys(byField).length > 0 && rest.length === 0 ? null : (rest[0] ?? answer?.error ?? UNREACHABLE));
227
+ } catch {
228
+ setError(UNREACHABLE);
229
+ }
230
+ return true;
231
+ })()
232
+ // (A `values` of the form's own that throws: nothing was sent.)
233
+ .catch(() => {
234
+ setError(UNREACHABLE);
235
+ return false;
236
+ })
237
+ .then((attempted) => {
238
+ sending.current = false;
239
+ setUploading(null);
240
+ setPending(false);
241
+ if (attempted) {
242
+ robot.current = null;
243
+ // Whatever Laravel said, the next attempt needs a new token and a new robot check.
244
+ setAttempt((count) => count + 1);
245
+ }
246
+ });
247
+ },
248
+ [slug, issued, values, onSent, upload],
249
+ );
250
+
251
+ const again = React.useCallback(() => {
252
+ setSent(false);
253
+ setError(null);
254
+ setErrors({});
255
+ }, []);
256
+
257
+ const guard = React.useMemo(
258
+ () => ({ honeypot: issued?.honeypot ?? null, turnstile: issued?.turnstile ?? false, action: issued?.action ?? null, attempt, robot: setRobot }),
259
+ [issued, attempt, setRobot],
260
+ );
261
+
262
+ return { submit, pending, uploading, sent, error, errors, again, upload, guard };
263
+ }
@@ -43,8 +43,26 @@ export interface ResourceAction {
43
43
 
44
44
  interface ResourceExtras {
45
45
  actions?: Record<string, Omit<ResourceAction, "key">>;
46
+ /** The date-and-time field its records are published by (the descriptor's "publish"). */
47
+ publish?: string;
48
+ /** Where on the site its public records are a blog: "/blog". */
49
+ blog?: string;
50
+ /** What its public form checks, and the address it has proved (the descriptor's "protect" and "confirm"). */
51
+ form?: ResourceForm;
46
52
  }
47
53
 
54
+ /** A resource's public form, as far as the dashboard needs to know of it. */
55
+ export interface ResourceForm {
56
+ protect?: { turnstile?: boolean; honeypot?: boolean; minSeconds?: number; email?: string };
57
+ /** The field holding an address that is proved by a link sent to it. Until then a record is "Not confirmed". */
58
+ confirm?: string;
59
+ }
60
+
61
+ /** What a record with a publish date is: on the site, waiting for its time, or neither. */
62
+ export type PublishState = "published" | "scheduled" | "draft";
63
+
64
+ export const PUBLISH_LABELS: Record<PublishState, string> = { published: "Published", scheduled: "Scheduled", draft: "Draft" };
65
+
48
66
  /** An app generated before there were any has no `extras` in its registry, and none to have. */
49
67
  const extras = ((registry as { extras?: unknown }).extras ?? {}) as Record<string, ResourceExtras>;
50
68
 
@@ -53,6 +71,27 @@ export function actionsOf(resourceName: string): ResourceAction[] {
53
71
  return Object.entries(extras[resourceName]?.actions ?? {}).map(([key, action]) => ({ ...action, key }));
54
72
  }
55
73
 
74
+ /** The field a resource's records are published by, if it has one. */
75
+ export function publishFieldOf(resourceName: string): string | undefined {
76
+ return extras[resourceName]?.publish;
77
+ }
78
+
79
+ /** What a resource's public form checks and confirms, if its descriptor says either. */
80
+ export function formOf(resourceName: string): ResourceForm | undefined {
81
+ return extras[resourceName]?.form;
82
+ }
83
+
84
+ /** Which of the three a record is, from its publish date and the time now. */
85
+ export function publishState(value: unknown, now: Date = new Date()): PublishState {
86
+ if (typeof value !== "string" || value === "") return "draft";
87
+ return new Date(value).getTime() <= now.getTime() ? "published" : "scheduled";
88
+ }
89
+
90
+ /** Links for the site's header to what the resources have put on the site: the blog. */
91
+ export function siteLinks(): { label: string; href: string }[] {
92
+ return Object.values(extras).flatMap((entry) => (entry.blog ? [{ label: "Blog", href: entry.blog }] : []));
93
+ }
94
+
56
95
  /** Whether an action applies to a record as it is now. Laravel asks the same before running it. */
57
96
  export function actionApplies(action: ResourceAction, record: Record<string, unknown>): boolean {
58
97
  return Object.entries(action.when ?? {}).every(([field, wanted]) => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.8.0",
3
+ "version": "0.9.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {