create-nevela 0.1.4 → 0.2.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 (32) hide show
  1. package/package.json +1 -1
  2. package/project.mjs +11 -0
  3. package/template/laravel/composer.json +61 -59
  4. package/template/laravel/config/nevela.php +70 -0
  5. package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +25 -2
  7. package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
  8. package/template/laravel/src/Console/SeedCommand.php +186 -124
  9. package/template/laravel/src/Console/UpdateCommand.php +64 -3
  10. package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
  11. package/template/laravel/src/Http/ResourceRequest.php +4 -1
  12. package/template/laravel/src/Http/UploadController.php +147 -0
  13. package/template/laravel/src/Media/FileTypes.php +97 -0
  14. package/template/laravel/src/Media/ImageOptimizer.php +252 -0
  15. package/template/laravel/src/Media/Profile.php +94 -0
  16. package/template/laravel/src/Media/UploadRejected.php +8 -0
  17. package/template/laravel/src/Media/Uploads.php +325 -0
  18. package/template/laravel/src/Models/Upload.php +46 -0
  19. package/template/laravel/src/Nevela.php +20 -1
  20. package/template/laravel/src/NevelaServiceProvider.php +24 -0
  21. package/template/laravel/src/Rules/UploadKey.php +27 -0
  22. package/template/laravel/src/Support/Fake.php +194 -171
  23. package/template/laravel/src/Support/Field.php +79 -10
  24. package/template/laravel/src/Support/TemplateSource.php +1 -1
  25. package/template/web/app/api/[...path]/route.ts +87 -0
  26. package/template/web/app/dashboard/actions.ts +26 -9
  27. package/template/web/components/dashboard/dashboard-header.tsx +24 -2
  28. package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
  29. package/template/web/components/dashboard/fields/file-field.tsx +25 -31
  30. package/template/web/lib/files.ts +21 -0
  31. package/template/web/package.json +2 -2
  32. package/template.mjs +1 -1
@@ -10,7 +10,7 @@ use InvalidArgumentException;
10
10
  */
11
11
  final class Field
12
12
  {
13
- public const KINDS = ['string', 'text', 'int', 'float', 'boolean', 'date', 'datetime', 'enum'];
13
+ public const KINDS = ['string', 'text', 'int', 'float', 'boolean', 'date', 'datetime', 'enum', 'belongsTo', 'file'];
14
14
 
15
15
  public const FORMATS = [
16
16
  'string' => ['email', 'url', 'tel', 'slug', 'color'],
@@ -18,6 +18,9 @@ final class Field
18
18
  'int' => ['percent', 'rating'],
19
19
  ];
20
20
 
21
+ /** What a file field may accept, in Flare's categories. The MIME types are in Media\FileTypes. */
22
+ public const ACCEPTS = ['any', 'image', 'pdf', 'video', 'audio', 'text', 'csv', 'document', 'spreadsheet', 'archive'];
23
+
21
24
  /** Shorthand types accepted by --fields, mapped to kind + format. */
22
25
  private const ALIASES = [
23
26
  'string' => ['string', null], 'text' => ['text', null],
@@ -34,6 +37,9 @@ final class Field
34
37
 
35
38
  /**
36
39
  * @param list<string> $options enum values
40
+ * @param string|null $target belongsTo: the resource this points at, e.g. "Category"
41
+ * @param list<string> $accept file: the categories it takes, e.g. ["image"]
42
+ * @param string|null $profile file: the image profile uploads are optimised with (config nevela.uploads.profiles)
37
43
  */
38
44
  public function __construct(
39
45
  public readonly string $name,
@@ -43,6 +49,9 @@ final class Field
43
49
  public readonly bool $unique = false,
44
50
  public readonly array $options = [],
45
51
  public readonly ?string $label = null,
52
+ public readonly ?string $target = null,
53
+ public readonly array $accept = [],
54
+ public readonly ?string $profile = null,
46
55
  ) {
47
56
  if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $name)) {
48
57
  throw new InvalidArgumentException("Field \"{$name}\" must be camelCase (e.g. publishedAt).");
@@ -64,32 +73,73 @@ final class Field
64
73
  throw new InvalidArgumentException("Enum value \"{$option}\" on \"{$name}\" may only use letters, digits, _ and -.");
65
74
  }
66
75
  }
76
+ if ($kind === 'belongsTo') {
77
+ if ($target === null || ! preg_match('/^[A-Z][A-Za-z0-9]*$/', $target)) {
78
+ throw new InvalidArgumentException("\"{$name}\" needs the resource it belongs to, e.g. category:belongsTo(Category).");
79
+ }
80
+ if (! str_ends_with($name, 'Id') || $name === 'Id') {
81
+ throw new InvalidArgumentException("A belongsTo field holds an id, so its name ends in \"Id\": {$name}Id.");
82
+ }
83
+ }
84
+ if ($kind === 'file') {
85
+ if ($accept === []) {
86
+ throw new InvalidArgumentException("File field \"{$name}\" needs what it accepts, e.g. {$name}:image or {$name}:file(pdf|image).");
87
+ }
88
+ foreach ($accept as $category) {
89
+ if (! in_array($category, self::ACCEPTS, true)) {
90
+ throw new InvalidArgumentException("\"{$category}\" is not something \"{$name}\" can accept. Use: ".implode(', ', self::ACCEPTS).'.');
91
+ }
92
+ }
93
+ if ($unique) {
94
+ throw new InvalidArgumentException("File field \"{$name}\" can't be unique.");
95
+ }
96
+ if ($profile !== null && ! preg_match('/^[a-z][a-z0-9-]*$/', $profile)) {
97
+ throw new InvalidArgumentException("Image profile \"{$profile}\" on \"{$name}\" may only use lower-case letters, digits and -.");
98
+ }
99
+ }
67
100
  }
68
101
 
69
102
  /**
70
103
  * Parse one `name:type` token. Suffix `?` = optional (nullable), `!` = unique; both may be combined.
71
- * Enums: `status:enum(draft|published)`.
104
+ *
105
+ * status:enum(draft|published)
106
+ * category:belongsTo(Category) stored as categoryId
107
+ * image:image an optimised image; image:image(product) names a profile
108
+ * manual:file(pdf|document) any other upload
72
109
  */
73
110
  public static function parse(string $token): self
74
111
  {
75
112
  $token = trim($token);
76
- if (! preg_match('/^([A-Za-z][A-Za-z0-9_]*):([a-z]+)(?:\(([^)]*)\))?([?!]{0,2})$/', $token, $m)) {
113
+ if (! preg_match('/^([A-Za-z][A-Za-z0-9_]*):([A-Za-z_]+)(?:\(([^)]*)\))?([?!]{0,2})$/', $token, $m)) {
77
114
  throw new InvalidArgumentException("Invalid field \"{$token}\". Expected name:type, e.g. price:money or status:enum(draft|live)?");
78
115
  }
79
116
  [, $name, $type, $args, $suffix] = $m + [3 => '', 4 => ''];
80
117
  $name = Naming::camel($name);
118
+ $required = ! str_contains($suffix, '?');
119
+ $unique = str_contains($suffix, '!');
120
+ $list = array_values(array_filter(array_map('trim', explode('|', $args)), fn ($v) => $v !== ''));
81
121
 
82
122
  if ($type === 'enum') {
83
- $options = array_values(array_filter(array_map('trim', explode('|', $args)), fn ($v) => $v !== ''));
123
+ return new self($name, 'enum', null, $required, $unique, $list);
124
+ }
125
+ if (in_array($type, ['belongsTo', 'belongs_to', 'belongsto'], true)) {
126
+ // "category" and "categoryId" both mean the column category_id.
127
+ $name = str_ends_with($name, 'Id') ? $name : $name.'Id';
84
128
 
85
- return new self($name, 'enum', null, ! str_contains($suffix, '?'), str_contains($suffix, '!'), $options);
129
+ return new self($name, 'belongsTo', null, $required, $unique, target: $list[0] ?? Naming::pascal(substr($name, 0, -2)));
130
+ }
131
+ if ($type === 'image') {
132
+ return new self($name, 'file', null, $required, $unique, accept: ['image'], profile: $list[0] ?? null);
133
+ }
134
+ if ($type === 'file') {
135
+ return new self($name, 'file', null, $required, $unique, accept: $list ?: ['any']);
86
136
  }
87
137
  if (! isset(self::ALIASES[$type])) {
88
- throw new InvalidArgumentException("Unknown type \"{$type}\" on \"{$name}\". Use one of: ".implode(', ', array_keys(self::ALIASES)).', enum(a|b).');
138
+ throw new InvalidArgumentException("Unknown type \"{$type}\" on \"{$name}\". Use one of: ".implode(', ', array_keys(self::ALIASES)).', enum(a|b), belongsTo(Resource), image, file(pdf|…).');
89
139
  }
90
140
  [$kind, $format] = self::ALIASES[$type];
91
141
 
92
- return new self($name, $kind, $format, ! str_contains($suffix, '?'), str_contains($suffix, '!'));
142
+ return new self($name, $kind, $format, $required, $unique);
93
143
  }
94
144
 
95
145
  /** @param array<string, mixed> $data */
@@ -103,6 +153,9 @@ final class Field
103
153
  (bool) ($data['unique'] ?? false),
104
154
  array_values($data['options'] ?? []),
105
155
  $data['label'] ?? null,
156
+ $data['target'] ?? null,
157
+ array_values($data['accept'] ?? []),
158
+ $data['profile'] ?? null,
106
159
  );
107
160
  }
108
161
 
@@ -113,6 +166,9 @@ final class Field
113
166
  'kind' => $this->kind,
114
167
  'format' => $this->format,
115
168
  'options' => $this->options ?: null,
169
+ 'target' => $this->target,
170
+ 'accept' => $this->accept ?: null,
171
+ 'profile' => $this->profile,
116
172
  'required' => $this->required ? null : false,
117
173
  'unique' => $this->unique ?: null,
118
174
  'label' => $this->label,
@@ -126,17 +182,30 @@ final class Field
126
182
 
127
183
  public function label(): string
128
184
  {
129
- return $this->label ?? Naming::humanize($this->name);
185
+ // "categoryId" reads as "Category", which is what the picker shows.
186
+ return $this->label ?? Naming::humanize($this->kind === 'belongsTo' ? substr($this->name, 0, -2) : $this->name);
187
+ }
188
+
189
+ /** belongsTo: the name of the relation on the model, e.g. "category" for categoryId. */
190
+ public function relation(): string
191
+ {
192
+ return substr($this->name, 0, -2);
193
+ }
194
+
195
+ /** Whether this is a file field that takes images only, and so is optimised and shown as a picture. */
196
+ public function isImage(): bool
197
+ {
198
+ return $this->kind === 'file' && $this->accept === ['image'];
130
199
  }
131
200
 
132
201
  public function sortable(): bool
133
202
  {
134
- return $this->kind !== 'text';
203
+ return ! in_array($this->kind, ['text', 'file'], true);
135
204
  }
136
205
 
137
206
  public function filterable(): bool
138
207
  {
139
- return $this->kind !== 'text';
208
+ return ! in_array($this->kind, ['text', 'file'], true);
140
209
  }
141
210
 
142
211
  public function searchable(): bool
@@ -27,7 +27,7 @@ final class TemplateSource
27
27
  private const GITHUB = 'https://codeload.github.com/MarkColeMukisa/nevela/tar.gz/refs/tags/v%s';
28
28
 
29
29
  /** Never part of an app's dashboard: build output, installs and local settings. */
30
- private const NEVER = ['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts'];
30
+ private const NEVER = ['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts', 'AGENTS.md', 'CLAUDE.md'];
31
31
 
32
32
  /**
33
33
  * @return array{files: array<string, string>, from: string} path => contents, and where it came from
@@ -0,0 +1,87 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { API_URL, sessionToken } from "@/lib/laravel";
3
+
4
+ /**
5
+ * The browser's way to the Laravel API, for the few things a server action can't do:
6
+ *
7
+ * - reading, for the relation picker's search (`/api/categories?q=…`),
8
+ * - showing stored files (`/api/_nevela/files/<key>`), and
9
+ * - uploading one (`PUT /api/_nevela/uploads/<Resource>/<field>`), which needs the
10
+ * browser's upload progress and is larger than a server action accepts.
11
+ *
12
+ * The token stays in its httpOnly cookie: it is read here, on the server, and sent on
13
+ * as a Bearer token. Laravel still decides what the person may see or store. Everything
14
+ * else that changes data goes through the server actions, so nothing but an upload is
15
+ * let through here as a write.
16
+ */
17
+ export const dynamic = "force-dynamic";
18
+
19
+ /** Larger than any field allows; Laravel enforces the real limit. This only stops a runaway request early. */
20
+ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
21
+
22
+ /**
23
+ * What is passed back from Laravel's answer. Not its length or encoding: fetch() hands
24
+ * over the body already decompressed, so the length Laravel gave may no longer be true.
25
+ */
26
+ const RESPONSE_HEADERS = ["content-type", "cache-control", "etag", "last-modified", "location", "x-content-type-options"];
27
+
28
+ type Context = { params: Promise<{ path: string[] }> };
29
+
30
+ function target(path: string[], search: string): string | null {
31
+ if (path.some((segment) => segment === "" || segment === "." || segment === "..")) return null;
32
+ return `${API_URL}/${path.map(encodeURIComponent).join("/")}${search}`;
33
+ }
34
+
35
+ async function forward(url: string, init: RequestInit, withToken: boolean): Promise<Response> {
36
+ const headers = new Headers(init.headers);
37
+ headers.set("Accept", headers.get("Accept") ?? "application/json");
38
+ const token = withToken ? await sessionToken() : undefined;
39
+ if (token) headers.set("Authorization", `Bearer ${token}`);
40
+
41
+ let upstream: Response;
42
+ try {
43
+ upstream = await fetch(url, { ...init, headers, cache: "no-store", redirect: "manual" });
44
+ } catch {
45
+ return Response.json({ error: "Can't reach the API. Is the Laravel app running?" }, { status: 502 });
46
+ }
47
+ const passed = new Headers();
48
+ for (const name of RESPONSE_HEADERS) {
49
+ const value = upstream.headers.get(name);
50
+ if (value) passed.set(name, value);
51
+ }
52
+ return new Response(upstream.status === 204 || upstream.status === 304 ? null : upstream.body, { status: upstream.status, headers: passed });
53
+ }
54
+
55
+ export async function GET(request: NextRequest, context: Context) {
56
+ const { path } = await context.params;
57
+ const url = target(path, request.nextUrl.search);
58
+ if (!url) return Response.json({ error: "Not found." }, { status: 404 });
59
+
60
+ // Stored files are public and cacheable, so they are asked for without the token.
61
+ const isFile = path[0] === "_nevela" && path[1] === "files";
62
+ const conditional: Record<string, string> = {};
63
+ for (const name of ["if-none-match", "if-modified-since"]) {
64
+ const value = request.headers.get(name);
65
+ if (value) conditional[name] = value;
66
+ }
67
+ return forward(url, { method: "GET", headers: { Accept: request.headers.get("accept") ?? "application/json", ...conditional } }, !isFile);
68
+ }
69
+
70
+ export async function PUT(request: NextRequest, context: Context) {
71
+ const { path } = await context.params;
72
+ const url = target(path, request.nextUrl.search);
73
+ if (!url || path[0] !== "_nevela" || path[1] !== "uploads") {
74
+ return Response.json({ error: "Only uploads are sent this way. Records are saved through the dashboard." }, { status: 405 });
75
+ }
76
+ if (Number(request.headers.get("content-length") ?? 0) > MAX_UPLOAD_BYTES) {
77
+ return Response.json({ error: "That file is too large to upload." }, { status: 413 });
78
+ }
79
+
80
+ // Read whole, then sent with its length: PHP's development server can't take a body
81
+ // that arrives in chunks, which is how a stream would be passed on.
82
+ const body = await request.arrayBuffer();
83
+ if (body.byteLength > MAX_UPLOAD_BYTES) {
84
+ return Response.json({ error: "That file is too large to upload." }, { status: 413 });
85
+ }
86
+ return forward(url, { method: "PUT", body, headers: { "Content-Type": request.headers.get("content-type") ?? "application/octet-stream" } }, true);
87
+ }
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
4
4
  import { can, storedFields, type PolicyAction } from "@flaredev/core";
5
5
  import type { FieldIssue } from "@/lib/resource/store";
6
6
  import { toCsv } from "@/lib/csv";
7
+ import { fileUrl } from "@/lib/files";
7
8
  import { resourcePath, dashboardSession, dashboardStore, policyFor } from "@/lib/dashboard";
8
9
 
9
10
  export type ActionResult<T = unknown> =
@@ -111,17 +112,33 @@ export async function updateRecordAction(resourceName: string, id: string, input
111
112
  return result;
112
113
  }
113
114
 
114
- const NO_FILES: ActionResult<never> = { ok: false, status: 501, error: "File fields aren't supported by Nevela yet." };
115
-
116
- /** File uploads go through Laravel's filesystem once Nevela generates file fields (see the roadmap). */
115
+ /**
116
+ * Where the browser sends a file for a file or image field.
117
+ *
118
+ * The address is this app's own /api route, which passes the file to Laravel with the
119
+ * person's token. Laravel checks the type, the size and the contents, optimises an image,
120
+ * and answers with the key it stored the file under: that key, not the empty one here, is
121
+ * what the form keeps.
122
+ */
117
123
  export async function createUploadUrlAction(
118
- _resourceName: string,
119
- _fieldKey: string,
120
- _file: { name: string; type: string; size: number },
124
+ resourceName: string,
125
+ fieldKey: string,
126
+ file: { name: string; type: string; size: number },
121
127
  ): Promise<ActionResult<{ url: string; key: string }>> {
122
- return NO_FILES;
128
+ const { allowed: inAdmin, role } = await dashboardSession();
129
+ const policy = policyFor(resourceName);
130
+ if (!inAdmin || !(can(policy, role, "create") || can(policy, role, "update"))) return forbidden("Your role can't add files to this record.");
131
+
132
+ const field = dashboardStore(resourceName).resource.fields[fieldKey];
133
+ if (!field || field.kind !== "file") return { ok: false, status: 404, error: "That isn't a file field." };
134
+
135
+ const query = new URLSearchParams({ name: file.name });
136
+ return { ok: true, data: { url: `/api/_nevela/uploads/${encodeURIComponent(resourceName)}/${encodeURIComponent(fieldKey)}?${query}`, key: "" } };
123
137
  }
124
138
 
125
- export async function createReadUrlAction(_resourceName: string, _fieldKey: string, _key: string, _downloadAs?: string): Promise<ActionResult<{ url: string }>> {
126
- return NO_FILES;
139
+ /** The address of a stored file. Kept for code that asks for one; the dashboard builds it with fileUrl(). */
140
+ export async function createReadUrlAction(_resourceName: string, _fieldKey: string, key: string): Promise<ActionResult<{ url: string }>> {
141
+ const { allowed: inAdmin } = await dashboardSession();
142
+ if (!inAdmin) return forbidden();
143
+ return { ok: true, data: { url: fileUrl(key) } };
127
144
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, useSyncExternalStore } from "react";
4
4
  import Link from "next/link";
5
5
  import { BellIcon, MoonIcon, SunIcon } from "lucide-react";
6
6
  import { Button } from "@/components/ui/button";
@@ -30,9 +30,31 @@ function applyTheme(theme: Theme) {
30
30
  if (theme !== "system") root.classList.add(theme);
31
31
  }
32
32
 
33
+ const DARK_QUERY = "(prefers-color-scheme: dark)";
34
+
35
+ function watchSystemTheme(onChange: () => void) {
36
+ const query = window.matchMedia(DARK_QUERY);
37
+ query.addEventListener("change", onChange);
38
+ return () => query.removeEventListener("change", onChange);
39
+ }
40
+
41
+ /**
42
+ * Whether the device prefers dark. The server can't know, so it and the first render in
43
+ * the browser both say no, and the real answer follows straight after: reading it during
44
+ * that first render would give the browser a different page from the one the server sent.
45
+ */
46
+ function useSystemDark() {
47
+ return useSyncExternalStore(
48
+ watchSystemTheme,
49
+ () => window.matchMedia(DARK_QUERY).matches,
50
+ () => false,
51
+ );
52
+ }
53
+
33
54
  export function DashboardHeader({ user, initialTheme, notices = [] }: { user: DashboardUser; initialTheme: Theme; notices?: Notice[] }) {
34
55
  const [theme, setTheme] = useState<Theme>(initialTheme);
35
- const dark = theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches);
56
+ const systemDark = useSystemDark();
57
+ const dark = theme === "dark" || (theme === "system" && systemDark);
36
58
  const needsAttention = notices.some((notice) => notice.tone === "warning");
37
59
 
38
60
  return (
@@ -1,55 +1,20 @@
1
- "use client";
2
-
3
- import { useEffect, useState } from "react";
4
1
  import { FileIcon } from "lucide-react";
5
- import { createReadUrlAction } from "@/app/dashboard/actions";
6
-
7
- /** Keys that are worth showing rather than naming. */
8
- const isImageKey = (key: string) => /\.(png|jpe?g|gif|webp|avif)$/i.test(key);
9
-
10
- /** "products/2026/09/kettle-a1b2c3.png" → "kettle-a1b2c3.png" */
11
- const basename = (key: string) => key.slice(key.lastIndexOf("/") + 1);
2
+ import { fileName, fileUrl, isImageKey } from "@/lib/files";
12
3
 
13
4
  /**
14
5
  * A stored file in a table cell or a record's detail.
15
6
  *
16
- * Objects in the bucket are private, so there is no URL to put in `src` — one has to
17
- * be signed, which is a server action, which makes this a client component. The same
18
- * round trip the form field does, for the same reason.
19
- *
20
- * An image becomes a thumbnail; anything else is its filename, which is the useful
21
- * part of a key nobody wants to read in full.
7
+ * An image becomes its thumbnail: the small rendition Laravel made when the file was
8
+ * uploaded, so a table of fifty products downloads fifty small pictures, not fifty
9
+ * full-size ones. Anything else is its filename, which is the useful part of a key
10
+ * nobody wants to read in full.
22
11
  */
23
- export function FileCell({
24
- resourceName,
25
- fieldKey,
26
- value,
27
- size = 36,
28
- }: {
29
- resourceName: string;
30
- fieldKey: string;
31
- value: string;
32
- size?: number;
33
- }) {
34
- const [url, setUrl] = useState<string | null>(null);
35
- const image = isImageKey(value);
36
-
37
- useEffect(() => {
38
- if (!image) return;
39
- let cancelled = false;
40
- void createReadUrlAction(resourceName, fieldKey, value).then((result) => {
41
- if (!cancelled && result.ok) setUrl(result.data.url);
42
- });
43
- return () => {
44
- cancelled = true;
45
- };
46
- }, [image, value, resourceName, fieldKey]);
47
-
48
- if (!image) {
12
+ export function FileCell({ value, size = 36 }: { resourceName?: string; fieldKey?: string; value: string; size?: number }) {
13
+ if (!isImageKey(value)) {
49
14
  return (
50
15
  <span className="inline-flex items-center gap-1.5 text-muted-foreground">
51
16
  <FileIcon aria-hidden="true" className="size-3.5" />
52
- <span className="max-w-[16ch] truncate">{basename(value)}</span>
17
+ <span className="max-w-[16ch] truncate">{fileName(value)}</span>
53
18
  </span>
54
19
  );
55
20
  }
@@ -59,10 +24,8 @@ export function FileCell({
59
24
  className="inline-flex items-center justify-center overflow-hidden rounded-md border bg-muted align-middle"
60
25
  style={{ width: size, height: size }}
61
26
  >
62
- {url ? (
63
- // eslint-disable-next-line @next/next/no-img-element -- signed, short-lived URL
64
- <img src={url} alt="" width={size} height={size} className="size-full object-cover" />
65
- ) : null}
27
+ {/* eslint-disable-next-line @next/next/no-img-element -- already the right size; next/image would resize it again */}
28
+ <img src={fileUrl(value, "thumb")} alt="" width={size} height={size} loading="lazy" decoding="async" className="size-full object-cover" />
66
29
  </span>
67
30
  );
68
31
  }
@@ -11,9 +11,10 @@ import {
11
11
  sniffMatches,
12
12
  type FileField as FileFieldDef,
13
13
  } from "@flaredev/core";
14
- import { createReadUrlAction, createUploadUrlAction } from "@/app/dashboard/actions";
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
18
  import { cn } from "@/lib/utils";
18
19
 
19
20
  interface Props {
@@ -29,11 +30,7 @@ interface Props {
29
30
 
30
31
  type Upload = { name: string; loaded: number; total: number; abort: () => void };
31
32
 
32
- /** "contacts/avatar/2026/09/<uuid>-me.png" → "me.png" */
33
- const fileName = (key: string) => key.split("/").pop()!.replace(/^[0-9a-f-]{36}-/, "");
34
-
35
33
  const IMAGE_TYPES = new Set<string>(FILE_CATEGORIES.image);
36
- const isImageKey = (key: string) => /\.(png|jpe?g|gif|webp|avif)$/i.test(key);
37
34
 
38
35
  const oneDecimal = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
39
36
  const whole = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
@@ -58,21 +55,26 @@ const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1
58
55
  /** "image/png" → "PNG", used when a file's contents don't match its type. */
59
56
  const typeName = (type: string) => (type.split("/")[1] ?? type).replace(/^vnd\..*\./, "").toUpperCase();
60
57
 
61
- /** PUT with progress events, which fetch() can't report for uploads. */
58
+ /**
59
+ * PUT with progress events, which fetch() can't report for uploads.
60
+ *
61
+ * Resolves with the key the file was stored under. Laravel chooses it, because an image
62
+ * is optimised on the way in and may come out as a different type from the one sent.
63
+ */
62
64
  function put(url: string, file: File, onProgress: (loaded: number) => void, signal: { abort?: () => void }) {
63
- return new Promise<void>((resolve, reject) => {
65
+ return new Promise<string | undefined>((resolve, reject) => {
64
66
  const request = new XMLHttpRequest();
65
67
  signal.abort = () => request.abort();
66
68
  request.upload.onprogress = (event) => onProgress(event.loaded);
67
69
  request.onload = () => {
68
- if (request.status >= 200 && request.status < 300) return resolve();
69
- let message = `Upload failed (${request.status}).`;
70
+ let answer: { error?: string; key?: string } = {};
70
71
  try {
71
- message = (JSON.parse(request.responseText) as { error?: string }).error ?? message;
72
+ answer = JSON.parse(request.responseText) as typeof answer;
72
73
  } catch {
73
- // Not JSON: keep the status message.
74
+ // Not JSON: the status says what happened.
74
75
  }
75
- reject(new Error(message));
76
+ if (request.status >= 200 && request.status < 300) return resolve(answer.key);
77
+ reject(new Error(answer.error ?? `Upload failed (${request.status}).`));
76
78
  };
77
79
  request.onerror = () => reject(new Error("Upload failed. Check your connection and try again."));
78
80
  request.onabort = () => reject(new DOMException("Upload cancelled.", "AbortError"));
@@ -83,9 +85,10 @@ function put(url: string, file: File, onProgress: (loaded: number) => void, sign
83
85
  }
84
86
 
85
87
  /**
86
- * Upload widget for a `file:[…]` field. Checks type, size and contents in the browser
87
- * first (the server repeats every check), uploads straight to storage through a signed
88
- * URL with progress, and stores the object key in the form.
88
+ * Upload widget for a file or image field. Checks type, size and contents in the browser
89
+ * first (Laravel repeats every check), uploads with progress, and stores the key Laravel
90
+ * answers with in the form. An image is optimised as it arrives: resized to the field's
91
+ * profile, with its smaller renditions made alongside.
89
92
  */
90
93
  export function FileField({ id, value, onChange, invalid, disabled, field, resourceName, fieldKey }: Props) {
91
94
  const input = useRef<HTMLInputElement>(null);
@@ -104,15 +107,8 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
104
107
 
105
108
  // Thumbnail for a stored image (a local preview replaces it while uploading).
106
109
  useEffect(() => {
107
- if (!value || !isImageKey(value)) return setPreview(null);
108
- let cancelled = false;
109
- void createReadUrlAction(resourceName, fieldKey, value).then((result) => {
110
- if (!cancelled && result.ok) setPreview(result.data.url);
111
- });
112
- return () => {
113
- cancelled = true;
114
- };
115
- }, [value, resourceName, fieldKey]);
110
+ setPreview(value && isImageKey(value) ? fileUrl(value, "thumb") : null);
111
+ }, [value]);
116
112
 
117
113
  async function check(file: File): Promise<string | null> {
118
114
  const type = file.type || "application/octet-stream";
@@ -142,8 +138,8 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
142
138
  try {
143
139
  const signed = await createUploadUrlAction(resourceName, fieldKey, { name: file.name, type: contentType, size: file.size });
144
140
  if (!signed.ok) throw new Error(signed.error);
145
- await put(signed.data.url, file, (loaded) => setUpload((current) => current && { ...current, loaded }), signal);
146
- onChange(signed.data.key);
141
+ const stored = await put(signed.data.url, file, (loaded) => setUpload((current) => current && { ...current, loaded }), signal);
142
+ onChange(stored ?? signed.data.key);
147
143
  setStatus(`${file.name} uploaded`);
148
144
  } catch (cause) {
149
145
  const cancelled = cause instanceof DOMException && cause.name === "AbortError";
@@ -157,10 +153,8 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
157
153
  }
158
154
  }
159
155
 
160
- async function open() {
161
- const result = await createReadUrlAction(resourceName, fieldKey, value);
162
- if (result.ok) window.open(result.data.url, "_blank", "noopener");
163
- else setError(result.error);
156
+ function open() {
157
+ window.open(fileUrl(value), "_blank", "noopener");
164
158
  }
165
159
 
166
160
  function remove() {
@@ -191,7 +185,7 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
191
185
  const thumbnail = (
192
186
  <div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted">
193
187
  {preview ? (
194
- // eslint-disable-next-line @next/next/no-img-element -- signed, short-lived URL
188
+ // eslint-disable-next-line @next/next/no-img-element -- a local preview, or a thumbnail already the right size
195
189
  <img src={preview} alt="" width={64} height={64} className="size-full object-cover" />
196
190
  ) : (
197
191
  <FileIcon aria-hidden="true" className="size-6 text-muted-foreground" />
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Stored files, as the browser asks for them.
3
+ *
4
+ * A record's file field holds a key such as `products/image/2026/10/<uuid>-kettle.webp`.
5
+ * An image's smaller renditions sit beside it with their name before the extension
6
+ * (`…-kettle.thumb.webp`), so the address of a thumbnail is worked out from the key with
7
+ * no lookup. Which renditions exist is set by the field's profile in the Laravel app's
8
+ * config/nevela.php; asking for one that was never made gets the image itself.
9
+ */
10
+
11
+ /** Keys that are worth showing rather than naming. */
12
+ export const isImageKey = (key: string) => /\.(png|jpe?g|gif|webp|avif)$/i.test(key);
13
+
14
+ /** The address of a stored file, or of one of its renditions ("thumb", "card", …). */
15
+ export function fileUrl(key: string, rendition?: string): string {
16
+ const path = rendition && isImageKey(key) ? key.replace(/(\.[A-Za-z0-9]+)$/, `.${rendition}$1`) : key;
17
+ return `/api/_nevela/files/${path.split("/").map(encodeURIComponent).join("/")}`;
18
+ }
19
+
20
+ /** "products/image/2026/10/<uuid>-kettle.webp" → "kettle.webp" */
21
+ export const fileName = (key: string) => key.slice(key.lastIndexOf("/") + 1).replace(/^[0-9a-f-]{36}-/, "");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,7 +22,7 @@
22
22
  "cmdk": "^1.1.1",
23
23
  "date-fns": "^4.4.0",
24
24
  "lucide-react": "^1.46.0",
25
- "next": "16.0.4",
25
+ "next": "16.3.8",
26
26
  "radix-ui": "^1.6.7",
27
27
  "react": "19.3.0",
28
28
  "react-day-picker": "^10.0.1",
package/template.mjs CHANGED
@@ -24,7 +24,7 @@ export const sources = {
24
24
  };
25
25
 
26
26
  /** Never part of a new app: build output, installs and local settings. */
27
- const NEVER = new Set(['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts', 'vendor', '.phpunit.result.cache', '.phpunit.cache', 'composer.lock']);
27
+ const NEVER = new Set(['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts', 'AGENTS.md', 'CLAUDE.md', 'vendor', '.phpunit.result.cache', '.phpunit.cache', 'composer.lock']);
28
28
 
29
29
  function copyTree(from, to, skip = () => false, relative = '') {
30
30
  fs.mkdirSync(to, { recursive: true });