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.
- package/package.json +1 -1
- package/project.mjs +11 -0
- package/template/laravel/composer.json +61 -59
- package/template/laravel/config/nevela.php +70 -0
- package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -2
- package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
- package/template/laravel/src/Console/SeedCommand.php +186 -124
- package/template/laravel/src/Console/UpdateCommand.php +64 -3
- package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
- package/template/laravel/src/Http/ResourceRequest.php +4 -1
- package/template/laravel/src/Http/UploadController.php +147 -0
- package/template/laravel/src/Media/FileTypes.php +97 -0
- package/template/laravel/src/Media/ImageOptimizer.php +252 -0
- package/template/laravel/src/Media/Profile.php +94 -0
- package/template/laravel/src/Media/UploadRejected.php +8 -0
- package/template/laravel/src/Media/Uploads.php +325 -0
- package/template/laravel/src/Models/Upload.php +46 -0
- package/template/laravel/src/Nevela.php +20 -1
- package/template/laravel/src/NevelaServiceProvider.php +24 -0
- package/template/laravel/src/Rules/UploadKey.php +27 -0
- package/template/laravel/src/Support/Fake.php +194 -171
- package/template/laravel/src/Support/Field.php +79 -10
- package/template/laravel/src/Support/TemplateSource.php +1 -1
- package/template/web/app/api/[...path]/route.ts +87 -0
- package/template/web/app/dashboard/actions.ts +26 -9
- package/template/web/components/dashboard/dashboard-header.tsx +24 -2
- package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
- package/template/web/components/dashboard/fields/file-field.tsx +25 -31
- package/template/web/lib/files.ts +21 -0
- package/template/web/package.json +2 -2
- 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
|
-
*
|
|
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_]*):([
|
|
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
|
-
|
|
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, '
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
resourceName: string,
|
|
125
|
+
fieldKey: string,
|
|
126
|
+
file: { name: string; type: string; size: number },
|
|
121
127
|
): Promise<ActionResult<{ url: string; key: string }>> {
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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">{
|
|
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
|
-
{
|
|
63
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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<
|
|
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
|
-
|
|
69
|
-
let message = `Upload failed (${request.status}).`;
|
|
70
|
+
let answer: { error?: string; key?: string } = {};
|
|
70
71
|
try {
|
|
71
|
-
|
|
72
|
+
answer = JSON.parse(request.responseText) as typeof answer;
|
|
72
73
|
} catch {
|
|
73
|
-
// Not JSON:
|
|
74
|
+
// Not JSON: the status says what happened.
|
|
74
75
|
}
|
|
75
|
-
|
|
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
|
|
87
|
-
* first (
|
|
88
|
-
*
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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 --
|
|
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
|
|
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.
|
|
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 });
|