create-nevela 0.9.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -527,6 +527,8 @@ final class ResourceGenerator
527
527
  foreach ($public->create as $field) {
528
528
  if ($d->fields[$field]->kind === 'file') {
529
529
  $lines[] = "Route::put('_nevela/uploads/{$d->name}/{$field}', [{$controller}, 'upload']){$for}->defaults('field', '{$field}')->middleware([{$throttle($public->createThrottle, 'upload')}{$more}])->name('{$d->slug}.upload.{$field}');";
530
+ // The same, beside the form's own address: a page knows the form by its slug, not by the resource's name.
531
+ $lines[] = "Route::put('{$d->slug}/_uploads/{$field}', [{$controller}, 'upload']){$for}->defaults('field', '{$field}')->middleware([{$throttle($public->createThrottle, 'upload')}{$more}])->name('{$d->slug}.upload.{$field}.here');";
530
532
  }
531
533
  }
532
534
  // What a protected form asks for before it is shown, and after each attempt.
@@ -25,6 +25,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
25
25
  * GET {prefix}/public/{slug}/{id} one of them, by id or by the field the section names
26
26
  * POST {prefix}/public/{slug} create one, from the fields the section lists
27
27
  * PUT {prefix}/public/_nevela/uploads/{Resource}/{field} a file for such a create
28
+ * PUT {prefix}/public/{slug}/_uploads/{field} the same, by the form's own address
28
29
  * GET {prefix}/public/_nevela/form/{slug} what a protected form sends back with it
29
30
  * POST {prefix}/public/_nevela/confirm/{slug}/{id}/{key} the link that proves an address
30
31
  *
@@ -22,7 +22,7 @@ use Nevela\Laravel\Support\Naming;
22
22
  final class Nevela
23
23
  {
24
24
  /** Written by `pnpm release`; the same number as the repository's git tag. */
25
- public const VERSION = '0.9.1';
25
+ public const VERSION = '0.9.2';
26
26
 
27
27
  /** @var array<string, Descriptor> */
28
28
  private static array $descriptors = [];
@@ -18,7 +18,7 @@ import { API_URL, callerHeaders, sessionToken } from "@/lib/laravel";
18
18
  *
19
19
  * And for a site's visitors, who have no session: what a resource's "public" section
20
20
  * opens (`/api/public/…`). Reading it, a form's create (`POST /api/public/<slug>`) and a
21
- * file for that form (`PUT /api/public/_nevela/uploads/<Resource>/<field>`) go through
21
+ * file for that form (`PUT /api/public/<slug>/_uploads/<field>`) go through
22
22
  * with no token. Laravel has those routes only for resources whose descriptor opens them.
23
23
  */
24
24
  export const dynamic = "force-dynamic";
@@ -104,8 +104,11 @@ export async function POST(request: NextRequest, context: Context) {
104
104
  export async function PUT(request: NextRequest, context: Context) {
105
105
  const { path } = await context.params;
106
106
  const url = target(path, request.nextUrl.search);
107
- // A visitor's file for a public form: /api/public/_nevela/uploads/<Resource>/<field>.
108
- const forVisitor = path.length === 5 && path[0] === "public" && path[1] === "_nevela" && path[2] === "uploads";
107
+ // A visitor's file for a public form: /api/public/_nevela/uploads/<Resource>/<field>,
108
+ // or by the form's own address, /api/public/<slug>/_uploads/<field>.
109
+ const forVisitor =
110
+ path[0] === "public" &&
111
+ ((path.length === 5 && path[1] === "_nevela" && path[2] === "uploads") || (path.length === 4 && !path[1].startsWith("_") && path[2] === "_uploads"));
109
112
  if (!url || !(forVisitor || (path[0] === "_nevela" && path[1] === "uploads"))) {
110
113
  return Response.json({ error: "Only uploads are sent this way. Records are saved through the dashboard." }, { status: 405 });
111
114
  }
@@ -12,11 +12,19 @@ interface Issued {
12
12
  action?: string | null;
13
13
  }
14
14
 
15
+ /** What Laravel says when it refuses something. */
16
+ interface Refused {
17
+ error?: string;
18
+ issues?: { path?: string; message?: string }[];
19
+ }
20
+
15
21
  export interface PublicForm {
16
22
  /** Give this to the form: `<form onSubmit={form.submit}>`. */
17
23
  submit: (event: React.FormEvent<HTMLFormElement>) => void;
18
- /** Being sent. */
24
+ /** Being sent: its files first, then the form. */
19
25
  pending: boolean;
26
+ /** The file field being uploaded right now, or null. */
27
+ uploading: string | null;
20
28
  /** Sent, and taken. */
21
29
  sent: boolean;
22
30
  /** What went wrong, where it isn't about one field: the robot check, too many tries, no connection. */
@@ -25,6 +33,12 @@ export interface PublicForm {
25
33
  errors: Record<string, string>;
26
34
  /** Start again after a form was sent, to send another. */
27
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 }>;
28
42
  /** For `<PublicFormGuard form={form} />`. Not for the form itself to read. */
29
43
  guard: {
30
44
  honeypot: string | null;
@@ -37,25 +51,34 @@ export interface PublicForm {
37
51
  }
38
52
 
39
53
  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>;
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>;
42
60
  onSent?: () => void;
43
61
  }
44
62
 
45
63
  const UNREACHABLE = "That didn't send. Check your connection and try again.";
46
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
+
47
68
  /**
48
69
  * A form anyone can send: a resource's public create, with whatever its descriptor's
49
70
  * "protect" asks for.
50
71
  *
51
72
  * ```tsx
52
- * const form = usePublicForm("contact-messages");
73
+ * const form = usePublicForm("internship-applications");
53
74
  *
54
75
  * <form onSubmit={form.submit}>
55
76
  * <input name="email" />
56
77
  * {form.errors.email}
78
+ * <input name="cv" type="file" accept="application/pdf" />
79
+ * {form.errors.cv}
57
80
  * <PublicFormGuard form={form} />
58
- * <button disabled={form.pending}>Send</button>
81
+ * <button disabled={form.pending}>{form.uploading ? "Uploading…" : "Send"}</button>
59
82
  * {form.error}
60
83
  * </form>
61
84
  * ```
@@ -65,6 +88,11 @@ const UNREACHABLE = "That didn't send. Check your connection and try again.";
65
88
  * `errors` under that field's name, and anything else to `error`. The checks themselves
66
89
  * are Laravel's. Nothing here decides who is let through.
67
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
+ *
68
96
  * A resource with no "protect" works too: there is no token to fetch, and the form is
69
97
  * sent as it is.
70
98
  */
@@ -72,11 +100,14 @@ export function usePublicForm(slug: string, { values, onSent }: PublicFormOption
72
100
  const [issued, setIssued] = React.useState<Issued | null>(null);
73
101
  const [attempt, setAttempt] = React.useState(0);
74
102
  const [pending, setPending] = React.useState(false);
103
+ const [uploading, setUploading] = React.useState<string | null>(null);
75
104
  const [sent, setSent] = React.useState(false);
76
105
  const [error, setError] = React.useState<string | null>(null);
77
106
  const [errors, setErrors] = React.useState<Record<string, string>>({});
78
107
  const robot = React.useRef<string | null>(null);
79
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 }>>({});
80
111
 
81
112
  // The token says when the form was handed out, so it is asked for when the form
82
113
  // appears, and a fresh one after each attempt.
@@ -99,44 +130,88 @@ export function usePublicForm(slug: string, { values, onSent }: PublicFormOption
99
130
  robot.current = token;
100
131
  }, []);
101
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
+
102
151
  const submit = React.useCallback(
103
152
  (event: React.FormEvent<HTMLFormElement>) => {
104
153
  event.preventDefault();
105
154
  if (sending.current) return;
106
155
  const element = event.currentTarget;
107
156
  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
157
 
123
158
  sending.current = true;
124
159
  setPending(true);
125
160
  setError(null);
126
161
  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) => {
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
+ });
133
206
  if (response.ok) {
207
+ // Its files belong to the record now.
208
+ uploaded.current = {};
134
209
  element.reset();
135
210
  setSent(true);
136
211
  onSent?.();
137
- return;
212
+ return true;
138
213
  }
139
- const answer = (await response.json().catch(() => null)) as { error?: string; issues?: { path?: string; message?: string }[] } | null;
214
+ const answer = (await response.json().catch(() => null)) as Refused | null;
140
215
  const byField: Record<string, string> = {};
141
216
  const rest: string[] = [];
142
217
  for (const issue of answer?.issues ?? []) {
@@ -145,19 +220,32 @@ export function usePublicForm(slug: string, { values, onSent }: PublicFormOption
145
220
  if (issue.path && element.elements.namedItem(issue.path)) byField[issue.path] ??= issue.message;
146
221
  else rest.push(issue.path ? `${issue.path}: ${issue.message}` : issue.message);
147
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];
148
225
  setErrors(byField);
149
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;
150
236
  })
151
- .catch(() => setError(UNREACHABLE))
152
- .finally(() => {
153
- sending.current = false;
237
+ .then((attempted) => {
238
+ sending.current = false;
239
+ setUploading(null);
240
+ setPending(false);
241
+ if (attempted) {
154
242
  robot.current = null;
155
- setPending(false);
156
- // Whatever happened, the next attempt needs a new token and a new robot check.
243
+ // Whatever Laravel said, the next attempt needs a new token and a new robot check.
157
244
  setAttempt((count) => count + 1);
158
- });
245
+ }
246
+ });
159
247
  },
160
- [slug, issued, values, onSent],
248
+ [slug, issued, values, onSent, upload],
161
249
  );
162
250
 
163
251
  const again = React.useCallback(() => {
@@ -171,5 +259,5 @@ export function usePublicForm(slug: string, { values, onSent }: PublicFormOption
171
259
  [issued, attempt, setRobot],
172
260
  );
173
261
 
174
- return { submit, pending, sent, error, errors, again, guard };
262
+ return { submit, pending, uploading, sent, error, errors, again, upload, guard };
175
263
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {