create-nevela 0.8.0 → 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 (61) 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 +131 -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 +88 -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/confirm/page.tsx +51 -0
  49. package/template/web/app/dashboard/actions.ts +15 -1
  50. package/template/web/components/dashboard/form-activity.tsx +53 -0
  51. package/template/web/components/dashboard/record-actions.tsx +29 -5
  52. package/template/web/components/dashboard/record-detail.tsx +2 -1
  53. package/template/web/components/dashboard/resource-form.tsx +19 -2
  54. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  55. package/template/web/components/dashboard/resource-table.tsx +32 -3
  56. package/template/web/components/dashboard/row-actions.tsx +33 -3
  57. package/template/web/components/marketing/site-header.tsx +9 -2
  58. package/template/web/components/public-form-guard.tsx +125 -0
  59. package/template/web/hooks/use-public-form.ts +175 -0
  60. package/template/web/lib/extras.ts +39 -0
  61. package/template/web/package.json +1 -1
@@ -0,0 +1,175 @@
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
+ export interface PublicForm {
16
+ /** Give this to the form: `<form onSubmit={form.submit}>`. */
17
+ submit: (event: React.FormEvent<HTMLFormElement>) => void;
18
+ /** Being sent. */
19
+ pending: boolean;
20
+ /** Sent, and taken. */
21
+ sent: boolean;
22
+ /** What went wrong, where it isn't about one field: the robot check, too many tries, no connection. */
23
+ error: string | null;
24
+ /** What is wrong with a field, by its name. */
25
+ errors: Record<string, string>;
26
+ /** Start again after a form was sent, to send another. */
27
+ again: () => void;
28
+ /** For `<PublicFormGuard form={form} />`. Not for the form itself to read. */
29
+ guard: {
30
+ honeypot: string | null;
31
+ turnstile: boolean;
32
+ action: string | null;
33
+ /** Goes up after every attempt: a robot token is good once, so the widget starts over. */
34
+ attempt: number;
35
+ robot: (token: string | null) => void;
36
+ };
37
+ }
38
+
39
+ export interface PublicFormOptions {
40
+ /** The record to send, from the form's fields. By default each named field, as typed, with empty ones left out. */
41
+ values?: (data: FormData) => Record<string, unknown>;
42
+ onSent?: () => void;
43
+ }
44
+
45
+ const UNREACHABLE = "That didn't send. Check your connection and try again.";
46
+
47
+ /**
48
+ * A form anyone can send: a resource's public create, with whatever its descriptor's
49
+ * "protect" asks for.
50
+ *
51
+ * ```tsx
52
+ * const form = usePublicForm("contact-messages");
53
+ *
54
+ * <form onSubmit={form.submit}>
55
+ * <input name="email" />
56
+ * {form.errors.email}
57
+ * <PublicFormGuard form={form} />
58
+ * <button disabled={form.pending}>Send</button>
59
+ * {form.error}
60
+ * </form>
61
+ * ```
62
+ *
63
+ * It asks Laravel for the form's token when the form is shown and again after every
64
+ * attempt, sends the fields with it, and sorts the answer: a problem with a field goes to
65
+ * `errors` under that field's name, and anything else to `error`. The checks themselves
66
+ * are Laravel's. Nothing here decides who is let through.
67
+ *
68
+ * A resource with no "protect" works too: there is no token to fetch, and the form is
69
+ * sent as it is.
70
+ */
71
+ export function usePublicForm(slug: string, { values, onSent }: PublicFormOptions = {}): PublicForm {
72
+ const [issued, setIssued] = React.useState<Issued | null>(null);
73
+ const [attempt, setAttempt] = React.useState(0);
74
+ const [pending, setPending] = React.useState(false);
75
+ const [sent, setSent] = React.useState(false);
76
+ const [error, setError] = React.useState<string | null>(null);
77
+ const [errors, setErrors] = React.useState<Record<string, string>>({});
78
+ const robot = React.useRef<string | null>(null);
79
+ const sending = React.useRef(false);
80
+
81
+ // The token says when the form was handed out, so it is asked for when the form
82
+ // appears, and a fresh one after each attempt.
83
+ React.useEffect(() => {
84
+ let current = true;
85
+ fetch(`/api/public/_nevela/form/${encodeURIComponent(slug)}`, { cache: "no-store", headers: { Accept: "application/json" } })
86
+ .then((response) => (response.ok ? (response.json() as Promise<Issued>) : null))
87
+ .then((form) => {
88
+ if (current) setIssued(form);
89
+ })
90
+ .catch(() => {
91
+ if (current) setIssued(null);
92
+ });
93
+ return () => {
94
+ current = false;
95
+ };
96
+ }, [slug, attempt]);
97
+
98
+ const setRobot = React.useCallback((token: string | null) => {
99
+ robot.current = token;
100
+ }, []);
101
+
102
+ const submit = React.useCallback(
103
+ (event: React.FormEvent<HTMLFormElement>) => {
104
+ event.preventDefault();
105
+ if (sending.current) return;
106
+ const element = event.currentTarget;
107
+ const data = new FormData(element);
108
+ const body: Record<string, unknown> = values ? { ...values(data) } : {};
109
+ if (!values) {
110
+ for (const [name, value] of data.entries()) {
111
+ // A file is uploaded on its own, and its key sent here: see the public uploads route.
112
+ if (typeof value === "string" && value !== "") body[name] = value;
113
+ }
114
+ }
115
+ // The field no person sees goes as it is, whatever `values` kept: it is how a bot is known.
116
+ if (issued?.honeypot) {
117
+ const bait = data.get(issued.honeypot);
118
+ if (typeof bait === "string" && bait !== "") body[issued.honeypot] = bait;
119
+ }
120
+ if (issued?.token) body._form = issued.token;
121
+ if (robot.current) body._turnstile = robot.current;
122
+
123
+ sending.current = true;
124
+ setPending(true);
125
+ setError(null);
126
+ setErrors({});
127
+ fetch(`/api/public/${encodeURIComponent(slug)}`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
130
+ body: JSON.stringify(body),
131
+ })
132
+ .then(async (response) => {
133
+ if (response.ok) {
134
+ element.reset();
135
+ setSent(true);
136
+ onSent?.();
137
+ return;
138
+ }
139
+ const answer = (await response.json().catch(() => null)) as { error?: string; issues?: { path?: string; message?: string }[] } | null;
140
+ const byField: Record<string, string> = {};
141
+ const rest: string[] = [];
142
+ for (const issue of answer?.issues ?? []) {
143
+ if (!issue.message) continue;
144
+ // The first thing wrong with a field is the one to fix first.
145
+ if (issue.path && element.elements.namedItem(issue.path)) byField[issue.path] ??= issue.message;
146
+ else rest.push(issue.path ? `${issue.path}: ${issue.message}` : issue.message);
147
+ }
148
+ setErrors(byField);
149
+ setError(Object.keys(byField).length > 0 && rest.length === 0 ? null : (rest[0] ?? answer?.error ?? UNREACHABLE));
150
+ })
151
+ .catch(() => setError(UNREACHABLE))
152
+ .finally(() => {
153
+ sending.current = false;
154
+ robot.current = null;
155
+ setPending(false);
156
+ // Whatever happened, the next attempt needs a new token and a new robot check.
157
+ setAttempt((count) => count + 1);
158
+ });
159
+ },
160
+ [slug, issued, values, onSent],
161
+ );
162
+
163
+ const again = React.useCallback(() => {
164
+ setSent(false);
165
+ setError(null);
166
+ setErrors({});
167
+ }, []);
168
+
169
+ const guard = React.useMemo(
170
+ () => ({ honeypot: issued?.honeypot ?? null, turnstile: issued?.turnstile ?? false, action: issued?.action ?? null, attempt, robot: setRobot }),
171
+ [issued, attempt, setRobot],
172
+ );
173
+
174
+ return { submit, pending, sent, error, errors, again, guard };
175
+ }
@@ -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.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {