@gigamusic/admin 0.3.0 → 2.0.0
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/README.md +29 -11
- package/package.json +6 -32
- package/src/lib/types.ts +0 -2
- package/src/client.ts +0 -16
- package/src/components/AdminNav.tsx +0 -50
- package/src/components/ReleaseForm.tsx +0 -556
- package/src/index.ts +0 -22
- package/src/pages/AdminEditReleasePage.tsx +0 -39
- package/src/pages/AdminHomePage.tsx +0 -38
- package/src/pages/AdminLayout.tsx +0 -20
- package/src/pages/AdminLinksPage.tsx +0 -187
- package/src/pages/AdminNewReleasePage.tsx +0 -12
- package/src/pages/AdminOrdersPage.tsx +0 -127
- package/src/pages/AdminReleasesPage.tsx +0 -87
- package/src/pages/AdminSettingsPage.tsx +0 -84
|
@@ -1,556 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { useState } from "react";
|
|
4
|
-
import { useRouter } from "next/navigation";
|
|
5
|
-
import { useSlot } from "@gigamusic/ui/client";
|
|
6
|
-
import { slugify } from "@gigamusic/core";
|
|
7
|
-
import { combinedName, deriveTrackArtistTitle } from "@gigamusic/audio/track-name";
|
|
8
|
-
|
|
9
|
-
interface ExistingTrack {
|
|
10
|
-
id: number;
|
|
11
|
-
name: string;
|
|
12
|
-
artist: string | null;
|
|
13
|
-
genre: string | null;
|
|
14
|
-
slug: string;
|
|
15
|
-
price: number;
|
|
16
|
-
trackNumber: number;
|
|
17
|
-
inRadio: boolean;
|
|
18
|
-
files: {
|
|
19
|
-
format: string;
|
|
20
|
-
fileName: string;
|
|
21
|
-
storageKey: string;
|
|
22
|
-
fileSize: number | null;
|
|
23
|
-
}[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface ReleaseFormProps {
|
|
27
|
-
release?: {
|
|
28
|
-
id: number;
|
|
29
|
-
name: string;
|
|
30
|
-
slug: string;
|
|
31
|
-
description: string | null;
|
|
32
|
-
price: number;
|
|
33
|
-
type: "album" | "single";
|
|
34
|
-
coverImageUrl: string | null;
|
|
35
|
-
releasedAt: Date | string | null;
|
|
36
|
-
isPublished: boolean;
|
|
37
|
-
inRadio: boolean;
|
|
38
|
-
tracks?: ExistingTrack[];
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface TrackInput {
|
|
43
|
-
existingId?: number;
|
|
44
|
-
artist: string;
|
|
45
|
-
title: string;
|
|
46
|
-
genre: string;
|
|
47
|
-
slug: string;
|
|
48
|
-
priceStr: string;
|
|
49
|
-
trackNumber: number;
|
|
50
|
-
inRadio: boolean;
|
|
51
|
-
wavFile: File | null;
|
|
52
|
-
existingWavName?: string;
|
|
53
|
-
existingWavStorageKey?: string;
|
|
54
|
-
existingMp3Name?: string;
|
|
55
|
-
existingMp3StorageKey?: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The release create/edit form. Talks to the admin API surface the consumer
|
|
60
|
-
* wires up — every fetch hits a stable `/api/admin/...` path so the only
|
|
61
|
-
* binding between this form and the backend is the network shape.
|
|
62
|
-
*
|
|
63
|
-
* Uploads happen client-side via presigned URLs from `/api/admin/upload/presign`;
|
|
64
|
-
* server-side transcoding + tagging is triggered by `/api/admin/upload/process`.
|
|
65
|
-
*/
|
|
66
|
-
export function ReleaseForm({ release }: ReleaseFormProps) {
|
|
67
|
-
const Button = useSlot("Button");
|
|
68
|
-
const router = useRouter();
|
|
69
|
-
const [loading, setLoading] = useState(false);
|
|
70
|
-
const [status, setStatus] = useState("");
|
|
71
|
-
const [error, setError] = useState("");
|
|
72
|
-
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
73
|
-
|
|
74
|
-
const [name, setName] = useState(release?.name ?? "");
|
|
75
|
-
const [slug, setSlug] = useState(release?.slug ?? "");
|
|
76
|
-
const [description, setDescription] = useState(release?.description ?? "");
|
|
77
|
-
const [priceStr, setPriceStr] = useState(
|
|
78
|
-
release ? (release.price / 100).toFixed(2) : "",
|
|
79
|
-
);
|
|
80
|
-
const [type, setType] = useState<"album" | "single">(release?.type ?? "single");
|
|
81
|
-
const [releasedAt, setReleasedAt] = useState(() => {
|
|
82
|
-
if (!release?.releasedAt) return "";
|
|
83
|
-
const d = new Date(release.releasedAt);
|
|
84
|
-
return d.toISOString().slice(0, 10);
|
|
85
|
-
});
|
|
86
|
-
const [isPublished, setIsPublished] = useState(release?.isPublished ?? false);
|
|
87
|
-
const [coverImage, setCoverImage] = useState<File | null>(null);
|
|
88
|
-
const [tracks, setTracks] = useState<TrackInput[]>(() => {
|
|
89
|
-
if (release?.tracks?.length) {
|
|
90
|
-
return release.tracks.map((t) => {
|
|
91
|
-
const wav = t.files.find((f) => f.format === "wav");
|
|
92
|
-
const mp3 = t.files.find((f) => f.format === "mp3");
|
|
93
|
-
const split = deriveTrackArtistTitle(t.name, t.artist);
|
|
94
|
-
return {
|
|
95
|
-
existingId: t.id,
|
|
96
|
-
artist: split.artist,
|
|
97
|
-
title: split.title,
|
|
98
|
-
genre: t.genre ?? "",
|
|
99
|
-
slug: t.slug,
|
|
100
|
-
priceStr: (t.price / 100).toFixed(2),
|
|
101
|
-
trackNumber: t.trackNumber,
|
|
102
|
-
inRadio: t.inRadio,
|
|
103
|
-
wavFile: null,
|
|
104
|
-
existingWavName: wav?.fileName,
|
|
105
|
-
existingWavStorageKey: wav?.storageKey,
|
|
106
|
-
existingMp3Name: mp3?.fileName,
|
|
107
|
-
existingMp3StorageKey: mp3?.storageKey,
|
|
108
|
-
};
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
return [
|
|
112
|
-
{
|
|
113
|
-
artist: "",
|
|
114
|
-
title: "",
|
|
115
|
-
genre: "",
|
|
116
|
-
slug: "",
|
|
117
|
-
priceStr: "1.99",
|
|
118
|
-
trackNumber: 1,
|
|
119
|
-
inRadio: true,
|
|
120
|
-
wavFile: null,
|
|
121
|
-
},
|
|
122
|
-
];
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
function updateTrack(index: number, patch: Partial<TrackInput>) {
|
|
126
|
-
setTracks((prev) => prev.map((t, i) => (i === index ? { ...t, ...patch } : t)));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function addTrack() {
|
|
130
|
-
setTracks((prev) => [
|
|
131
|
-
...prev,
|
|
132
|
-
{
|
|
133
|
-
artist: "",
|
|
134
|
-
title: "",
|
|
135
|
-
genre: "",
|
|
136
|
-
slug: "",
|
|
137
|
-
priceStr: "1.99",
|
|
138
|
-
trackNumber: prev.length + 1,
|
|
139
|
-
inRadio: true,
|
|
140
|
-
wavFile: null,
|
|
141
|
-
},
|
|
142
|
-
]);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function removeTrack(index: number) {
|
|
146
|
-
setTracks((prev) =>
|
|
147
|
-
prev
|
|
148
|
-
.filter((_, i) => i !== index)
|
|
149
|
-
.map((t, i) => ({ ...t, trackNumber: i + 1 })),
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function presignAndUpload(file: File, key: string): Promise<string> {
|
|
154
|
-
const contentType = file.type || "application/octet-stream";
|
|
155
|
-
const presignRes = await fetch("/api/admin/upload/presign", {
|
|
156
|
-
method: "POST",
|
|
157
|
-
headers: { "Content-Type": "application/json" },
|
|
158
|
-
body: JSON.stringify({ key, contentType }),
|
|
159
|
-
});
|
|
160
|
-
if (!presignRes.ok) {
|
|
161
|
-
throw new Error(`Failed to get upload URL (${presignRes.status})`);
|
|
162
|
-
}
|
|
163
|
-
const { url, publicUrl } = (await presignRes.json()) as {
|
|
164
|
-
url: string;
|
|
165
|
-
publicUrl: string;
|
|
166
|
-
};
|
|
167
|
-
const uploadRes = await fetch(url, {
|
|
168
|
-
method: "PUT",
|
|
169
|
-
headers: { "Content-Type": contentType },
|
|
170
|
-
body: file,
|
|
171
|
-
});
|
|
172
|
-
if (!uploadRes.ok) throw new Error("Upload failed");
|
|
173
|
-
return publicUrl;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function handleSubmit(e: React.FormEvent) {
|
|
177
|
-
e.preventDefault();
|
|
178
|
-
setLoading(true);
|
|
179
|
-
setError("");
|
|
180
|
-
setFieldErrors({});
|
|
181
|
-
setStatus("");
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const parsedPrice = parseFloat(priceStr);
|
|
185
|
-
const price = isNaN(parsedPrice) ? 0 : Math.round(parsedPrice * 100);
|
|
186
|
-
if (isPublished && price <= 0) {
|
|
187
|
-
setError("Please enter a valid release price.");
|
|
188
|
-
setLoading(false);
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
let coverImageUrl: string | null = release?.coverImageUrl ?? null;
|
|
193
|
-
if (coverImage) {
|
|
194
|
-
setStatus("Uploading cover image...");
|
|
195
|
-
coverImageUrl = await presignAndUpload(
|
|
196
|
-
coverImage,
|
|
197
|
-
`images/covers/${coverImage.name}`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const trackData = [];
|
|
202
|
-
for (const track of tracks) {
|
|
203
|
-
const trackName = combinedName(track.artist, track.title);
|
|
204
|
-
const parsedTrackPrice = parseFloat(track.priceStr);
|
|
205
|
-
const trackPrice = isNaN(parsedTrackPrice)
|
|
206
|
-
? 0
|
|
207
|
-
: Math.round(parsedTrackPrice * 100);
|
|
208
|
-
|
|
209
|
-
let wavUrl = track.existingWavStorageKey ?? null;
|
|
210
|
-
let mp3Url = track.existingMp3StorageKey ?? null;
|
|
211
|
-
let wavName = track.existingWavName ?? "";
|
|
212
|
-
|
|
213
|
-
if (track.wavFile) {
|
|
214
|
-
setStatus(`Uploading & transcoding "${trackName || track.wavFile.name}"...`);
|
|
215
|
-
const key = `audio/${slug}/${track.trackNumber}/${track.wavFile.name}`;
|
|
216
|
-
await presignAndUpload(track.wavFile, key);
|
|
217
|
-
|
|
218
|
-
const processRes = await fetch("/api/admin/upload/process", {
|
|
219
|
-
method: "POST",
|
|
220
|
-
headers: { "Content-Type": "application/json" },
|
|
221
|
-
body: JSON.stringify({
|
|
222
|
-
key,
|
|
223
|
-
trackId: track.existingId ?? null,
|
|
224
|
-
metadata: {
|
|
225
|
-
title: track.title || undefined,
|
|
226
|
-
artist: track.artist || undefined,
|
|
227
|
-
album: name || undefined,
|
|
228
|
-
genre: track.genre || undefined,
|
|
229
|
-
trackNumber: track.trackNumber,
|
|
230
|
-
trackTotal: tracks.length,
|
|
231
|
-
year: releasedAt
|
|
232
|
-
? new Date(releasedAt + "T00:00:00Z").getUTCFullYear()
|
|
233
|
-
: undefined,
|
|
234
|
-
},
|
|
235
|
-
coverImageUrl,
|
|
236
|
-
}),
|
|
237
|
-
});
|
|
238
|
-
const processBody = (await processRes.json()) as {
|
|
239
|
-
wavUrl?: string;
|
|
240
|
-
mp3Url?: string;
|
|
241
|
-
error?: string;
|
|
242
|
-
};
|
|
243
|
-
if (!processRes.ok) {
|
|
244
|
-
throw new Error(processBody.error ?? "Transcoding failed");
|
|
245
|
-
}
|
|
246
|
-
wavUrl = processBody.wavUrl ?? null;
|
|
247
|
-
mp3Url = processBody.mp3Url ?? null;
|
|
248
|
-
wavName = track.wavFile.name;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const files: Array<{
|
|
252
|
-
format: string;
|
|
253
|
-
fileName: string;
|
|
254
|
-
storageKey: string;
|
|
255
|
-
fileSize: number;
|
|
256
|
-
}> = [];
|
|
257
|
-
if (wavUrl) {
|
|
258
|
-
files.push({
|
|
259
|
-
format: "wav",
|
|
260
|
-
fileName: wavName || "track.wav",
|
|
261
|
-
storageKey: wavUrl,
|
|
262
|
-
fileSize: 0,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
if (mp3Url) {
|
|
266
|
-
files.push({
|
|
267
|
-
format: "mp3",
|
|
268
|
-
fileName: (wavName || "track.wav").replace(/\.wav$/i, ".mp3"),
|
|
269
|
-
storageKey: mp3Url,
|
|
270
|
-
fileSize: 0,
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
trackData.push({
|
|
275
|
-
id: track.existingId,
|
|
276
|
-
name: trackName,
|
|
277
|
-
artist: track.artist || null,
|
|
278
|
-
genre: track.genre || null,
|
|
279
|
-
slug: track.slug || slugify(trackName) || `track-${track.trackNumber}`,
|
|
280
|
-
price: trackPrice,
|
|
281
|
-
trackNumber: track.trackNumber,
|
|
282
|
-
inRadio: track.inRadio,
|
|
283
|
-
files,
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
setStatus("Saving release...");
|
|
288
|
-
const url = release
|
|
289
|
-
? `/api/admin/releases/${release.id}`
|
|
290
|
-
: "/api/admin/releases";
|
|
291
|
-
const res = await fetch(url, {
|
|
292
|
-
method: release ? "PUT" : "POST",
|
|
293
|
-
headers: { "Content-Type": "application/json" },
|
|
294
|
-
body: JSON.stringify({
|
|
295
|
-
name,
|
|
296
|
-
slug,
|
|
297
|
-
description: description || null,
|
|
298
|
-
price,
|
|
299
|
-
type,
|
|
300
|
-
coverImageUrl,
|
|
301
|
-
releasedAt: releasedAt
|
|
302
|
-
? new Date(releasedAt + "T00:00:00Z").toISOString()
|
|
303
|
-
: null,
|
|
304
|
-
isPublished,
|
|
305
|
-
tracks: trackData,
|
|
306
|
-
}),
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
if (!res.ok) {
|
|
310
|
-
const data = (await res.json().catch(() => ({}))) as {
|
|
311
|
-
error?: string;
|
|
312
|
-
fieldErrors?: Record<string, string[]>;
|
|
313
|
-
};
|
|
314
|
-
setError(data.error ?? "Something went wrong");
|
|
315
|
-
if (data.fieldErrors) {
|
|
316
|
-
const flat: Record<string, string> = {};
|
|
317
|
-
for (const [k, msgs] of Object.entries(data.fieldErrors)) {
|
|
318
|
-
if (Array.isArray(msgs) && msgs[0]) flat[k] = msgs[0];
|
|
319
|
-
}
|
|
320
|
-
setFieldErrors(flat);
|
|
321
|
-
}
|
|
322
|
-
setLoading(false);
|
|
323
|
-
setStatus("");
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const saved = (await res.json()) as { id: number };
|
|
328
|
-
setLoading(false);
|
|
329
|
-
setStatus("");
|
|
330
|
-
router.push(`/admin/releases/${saved.id}/edit`);
|
|
331
|
-
router.refresh();
|
|
332
|
-
} catch (err) {
|
|
333
|
-
setError(err instanceof Error ? err.message : "Something went wrong");
|
|
334
|
-
setLoading(false);
|
|
335
|
-
setStatus("");
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
return (
|
|
340
|
-
<form onSubmit={handleSubmit} className="gm-admin-form space-y-6 rounded-lg border border-border p-6">
|
|
341
|
-
<Field label="Release Name" id="name" error={fieldErrors.name}>
|
|
342
|
-
<input
|
|
343
|
-
id="name"
|
|
344
|
-
value={name}
|
|
345
|
-
onChange={(e) => {
|
|
346
|
-
setName(e.target.value);
|
|
347
|
-
if (!release) setSlug(slugify(e.target.value));
|
|
348
|
-
}}
|
|
349
|
-
required
|
|
350
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
351
|
-
/>
|
|
352
|
-
</Field>
|
|
353
|
-
|
|
354
|
-
<Field label="Slug" id="slug" error={fieldErrors.slug}>
|
|
355
|
-
<input
|
|
356
|
-
id="slug"
|
|
357
|
-
value={slug}
|
|
358
|
-
onChange={(e) => setSlug(e.target.value)}
|
|
359
|
-
required
|
|
360
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
361
|
-
/>
|
|
362
|
-
</Field>
|
|
363
|
-
|
|
364
|
-
<Field label="Description" id="description">
|
|
365
|
-
<textarea
|
|
366
|
-
id="description"
|
|
367
|
-
value={description}
|
|
368
|
-
onChange={(e) => setDescription(e.target.value)}
|
|
369
|
-
rows={3}
|
|
370
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
371
|
-
/>
|
|
372
|
-
</Field>
|
|
373
|
-
|
|
374
|
-
<div className="grid grid-cols-2 gap-4">
|
|
375
|
-
<Field label="Release Price (USD)" id="price" error={fieldErrors.price}>
|
|
376
|
-
<input
|
|
377
|
-
id="price"
|
|
378
|
-
type="number"
|
|
379
|
-
step="0.01"
|
|
380
|
-
min="0"
|
|
381
|
-
value={priceStr}
|
|
382
|
-
onChange={(e) => setPriceStr(e.target.value)}
|
|
383
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
384
|
-
/>
|
|
385
|
-
</Field>
|
|
386
|
-
<Field label="Type" id="type">
|
|
387
|
-
<select
|
|
388
|
-
id="type"
|
|
389
|
-
value={type}
|
|
390
|
-
onChange={(e) => setType(e.target.value as "album" | "single")}
|
|
391
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
392
|
-
>
|
|
393
|
-
<option value="single">Single</option>
|
|
394
|
-
<option value="album">Album</option>
|
|
395
|
-
</select>
|
|
396
|
-
</Field>
|
|
397
|
-
</div>
|
|
398
|
-
|
|
399
|
-
<Field label="Release Date" id="releasedAt">
|
|
400
|
-
<input
|
|
401
|
-
id="releasedAt"
|
|
402
|
-
type="date"
|
|
403
|
-
value={releasedAt}
|
|
404
|
-
onChange={(e) => setReleasedAt(e.target.value)}
|
|
405
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
406
|
-
/>
|
|
407
|
-
</Field>
|
|
408
|
-
|
|
409
|
-
<Field label="Cover Image" id="cover">
|
|
410
|
-
<input
|
|
411
|
-
id="cover"
|
|
412
|
-
type="file"
|
|
413
|
-
accept="image/*"
|
|
414
|
-
onChange={(e) => setCoverImage(e.target.files?.[0] ?? null)}
|
|
415
|
-
className="block text-sm"
|
|
416
|
-
/>
|
|
417
|
-
</Field>
|
|
418
|
-
|
|
419
|
-
<div className="space-y-3">
|
|
420
|
-
<p className="text-sm font-medium">Tracks</p>
|
|
421
|
-
{tracks.map((track, index) => (
|
|
422
|
-
<div key={index} className="space-y-3 rounded border border-border p-4">
|
|
423
|
-
<div className="flex items-center justify-between">
|
|
424
|
-
<span className="text-sm font-medium">Track {track.trackNumber}</span>
|
|
425
|
-
{tracks.length > 1 && (
|
|
426
|
-
<Button variant="ghost" size="sm" onClick={() => removeTrack(index)}>
|
|
427
|
-
Remove
|
|
428
|
-
</Button>
|
|
429
|
-
)}
|
|
430
|
-
</div>
|
|
431
|
-
<div className="grid grid-cols-2 gap-3">
|
|
432
|
-
<Field label="Artist" id={`artist-${index}`} error={fieldErrors[`tracks[${index}].artist`]}>
|
|
433
|
-
<input
|
|
434
|
-
value={track.artist}
|
|
435
|
-
onChange={(e) => {
|
|
436
|
-
const value = e.target.value;
|
|
437
|
-
updateTrack(index, {
|
|
438
|
-
artist: value,
|
|
439
|
-
slug:
|
|
440
|
-
track.existingId == null
|
|
441
|
-
? slugify(combinedName(value, track.title))
|
|
442
|
-
: track.slug,
|
|
443
|
-
});
|
|
444
|
-
}}
|
|
445
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
446
|
-
/>
|
|
447
|
-
</Field>
|
|
448
|
-
<Field label="Track Title" id={`title-${index}`} error={fieldErrors[`tracks[${index}].name`]}>
|
|
449
|
-
<input
|
|
450
|
-
value={track.title}
|
|
451
|
-
onChange={(e) => {
|
|
452
|
-
const value = e.target.value;
|
|
453
|
-
updateTrack(index, {
|
|
454
|
-
title: value,
|
|
455
|
-
slug:
|
|
456
|
-
track.existingId == null
|
|
457
|
-
? slugify(combinedName(track.artist, value))
|
|
458
|
-
: track.slug,
|
|
459
|
-
});
|
|
460
|
-
}}
|
|
461
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
462
|
-
/>
|
|
463
|
-
</Field>
|
|
464
|
-
</div>
|
|
465
|
-
<div className="grid grid-cols-2 gap-3">
|
|
466
|
-
<Field label="Price (USD)" id={`price-${index}`} error={fieldErrors[`tracks[${index}].price`]}>
|
|
467
|
-
<input
|
|
468
|
-
type="number"
|
|
469
|
-
step="0.01"
|
|
470
|
-
min="0"
|
|
471
|
-
value={track.priceStr}
|
|
472
|
-
onChange={(e) => updateTrack(index, { priceStr: e.target.value })}
|
|
473
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
474
|
-
/>
|
|
475
|
-
</Field>
|
|
476
|
-
<Field label="Slug" id={`slug-${index}`}>
|
|
477
|
-
<input
|
|
478
|
-
value={track.slug}
|
|
479
|
-
onChange={(e) => updateTrack(index, { slug: e.target.value })}
|
|
480
|
-
className="block w-full rounded border border-border bg-background px-3 py-2 text-sm"
|
|
481
|
-
/>
|
|
482
|
-
</Field>
|
|
483
|
-
</div>
|
|
484
|
-
<Field label="WAV File" id={`wav-${index}`} error={fieldErrors[`tracks[${index}].files`]}>
|
|
485
|
-
<input
|
|
486
|
-
type="file"
|
|
487
|
-
accept=".wav"
|
|
488
|
-
onChange={(e) => updateTrack(index, { wavFile: e.target.files?.[0] ?? null })}
|
|
489
|
-
className="block text-sm"
|
|
490
|
-
/>
|
|
491
|
-
{track.existingWavName && !track.wavFile && (
|
|
492
|
-
<p className="text-xs text-muted-foreground">
|
|
493
|
-
Current: {track.existingWavName}
|
|
494
|
-
{track.existingMp3StorageKey && " (320k mp3 generated)"}
|
|
495
|
-
</p>
|
|
496
|
-
)}
|
|
497
|
-
</Field>
|
|
498
|
-
</div>
|
|
499
|
-
))}
|
|
500
|
-
<Button variant="outline" size="sm" onClick={addTrack}>
|
|
501
|
-
Add Track
|
|
502
|
-
</Button>
|
|
503
|
-
</div>
|
|
504
|
-
|
|
505
|
-
<label className="flex items-center gap-2 text-sm">
|
|
506
|
-
<input
|
|
507
|
-
type="checkbox"
|
|
508
|
-
checked={isPublished}
|
|
509
|
-
onChange={(e) => setIsPublished(e.target.checked)}
|
|
510
|
-
/>
|
|
511
|
-
Published
|
|
512
|
-
</label>
|
|
513
|
-
|
|
514
|
-
{loading && status && (
|
|
515
|
-
<p className="text-sm text-muted-foreground">{status}</p>
|
|
516
|
-
)}
|
|
517
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
518
|
-
|
|
519
|
-
<div className="flex gap-3">
|
|
520
|
-
<Button type="submit" disabled={loading}>
|
|
521
|
-
{loading ? "Saving..." : release ? "Update Release" : "Save Release"}
|
|
522
|
-
</Button>
|
|
523
|
-
<Button variant="outline" onClick={() => router.back()}>
|
|
524
|
-
Cancel
|
|
525
|
-
</Button>
|
|
526
|
-
</div>
|
|
527
|
-
</form>
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
function Field({
|
|
532
|
-
label,
|
|
533
|
-
id,
|
|
534
|
-
error,
|
|
535
|
-
children,
|
|
536
|
-
}: {
|
|
537
|
-
label: string;
|
|
538
|
-
id: string;
|
|
539
|
-
error?: string;
|
|
540
|
-
children: React.ReactNode;
|
|
541
|
-
}) {
|
|
542
|
-
// Clone the single form control child so callers don't have to repeat
|
|
543
|
-
// `id={...}` on the input — the label's htmlFor stays the source of truth.
|
|
544
|
-
const child = React.isValidElement(children)
|
|
545
|
-
? React.cloneElement(children as React.ReactElement<{ id?: string }>, { id })
|
|
546
|
-
: children;
|
|
547
|
-
return (
|
|
548
|
-
<div className="space-y-1">
|
|
549
|
-
<label htmlFor={id} className="text-sm font-medium">
|
|
550
|
-
{label}
|
|
551
|
-
</label>
|
|
552
|
-
{child}
|
|
553
|
-
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
554
|
-
</div>
|
|
555
|
-
);
|
|
556
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// Default entry — page components + shared form components. Server-only
|
|
2
|
-
// handler factories live in `./server`; the consumer imports them separately
|
|
3
|
-
// to keep "use client" boundaries clean.
|
|
4
|
-
|
|
5
|
-
export { AdminLayout } from "./pages/AdminLayout";
|
|
6
|
-
export { AdminHomePage } from "./pages/AdminHomePage";
|
|
7
|
-
export { AdminReleasesPage } from "./pages/AdminReleasesPage";
|
|
8
|
-
export { AdminNewReleasePage } from "./pages/AdminNewReleasePage";
|
|
9
|
-
export { AdminEditReleasePage } from "./pages/AdminEditReleasePage";
|
|
10
|
-
export { AdminLinksPage } from "./pages/AdminLinksPage";
|
|
11
|
-
export { AdminSettingsPage } from "./pages/AdminSettingsPage";
|
|
12
|
-
export { AdminOrdersPage } from "./pages/AdminOrdersPage";
|
|
13
|
-
|
|
14
|
-
export { ReleaseForm } from "./components/ReleaseForm";
|
|
15
|
-
export type { ReleaseFormProps } from "./components/ReleaseForm";
|
|
16
|
-
export { AdminNav } from "./components/AdminNav";
|
|
17
|
-
|
|
18
|
-
export type {
|
|
19
|
-
AdminDeps,
|
|
20
|
-
AdminLogger,
|
|
21
|
-
RouteHandler,
|
|
22
|
-
} from "./lib/types";
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
4
|
-
import { ReleaseForm, type ReleaseFormProps } from "../components/ReleaseForm";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Edit-release page. The consumer's route handler hands us the `id` from
|
|
8
|
-
* `params`; we fetch the full record from `/api/admin/releases/{id}` and
|
|
9
|
-
* mount `<ReleaseForm>` once it's loaded.
|
|
10
|
-
*/
|
|
11
|
-
export function AdminEditReleasePage({ id }: { id: string }) {
|
|
12
|
-
const [release, setRelease] = useState<ReleaseFormProps["release"] | null>(
|
|
13
|
-
null,
|
|
14
|
-
);
|
|
15
|
-
const [error, setError] = useState("");
|
|
16
|
-
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
let cancelled = false;
|
|
19
|
-
fetch(`/api/admin/releases/${id}`)
|
|
20
|
-
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("Load failed"))))
|
|
21
|
-
.then((data) => {
|
|
22
|
-
if (!cancelled) setRelease(data as ReleaseFormProps["release"]);
|
|
23
|
-
})
|
|
24
|
-
.catch((err) => !cancelled && setError(String(err)));
|
|
25
|
-
return () => {
|
|
26
|
-
cancelled = true;
|
|
27
|
-
};
|
|
28
|
-
}, [id]);
|
|
29
|
-
|
|
30
|
-
if (error) return <p className="text-sm text-destructive">{error}</p>;
|
|
31
|
-
if (!release) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<div className="max-w-2xl">
|
|
35
|
-
<h1 className="mb-6 text-2xl font-semibold">{release.name}</h1>
|
|
36
|
-
<ReleaseForm release={release} />
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import Link from "next/link";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Default admin landing page. Just links into the major sections. Consumers
|
|
7
|
-
* who want to drop directly to releases can simply re-export
|
|
8
|
-
* `AdminReleasesPage` from `/admin/page.tsx` instead.
|
|
9
|
-
*/
|
|
10
|
-
export function AdminHomePage() {
|
|
11
|
-
return (
|
|
12
|
-
<div className="space-y-6">
|
|
13
|
-
<h1 className="text-2xl font-semibold">Admin</h1>
|
|
14
|
-
<ul className="space-y-2 text-sm">
|
|
15
|
-
<li>
|
|
16
|
-
<Link href="/admin/releases" className="hover:underline">
|
|
17
|
-
Releases
|
|
18
|
-
</Link>
|
|
19
|
-
</li>
|
|
20
|
-
<li>
|
|
21
|
-
<Link href="/admin/orders" className="hover:underline">
|
|
22
|
-
Orders
|
|
23
|
-
</Link>
|
|
24
|
-
</li>
|
|
25
|
-
<li>
|
|
26
|
-
<Link href="/admin/links" className="hover:underline">
|
|
27
|
-
Links
|
|
28
|
-
</Link>
|
|
29
|
-
</li>
|
|
30
|
-
<li>
|
|
31
|
-
<Link href="/admin/settings" className="hover:underline">
|
|
32
|
-
Settings
|
|
33
|
-
</Link>
|
|
34
|
-
</li>
|
|
35
|
-
</ul>
|
|
36
|
-
</div>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
|
-
import { AdminNav } from "../components/AdminNav";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Visual chrome for the admin section: nav bar + content container. This
|
|
6
|
-
* component does **not** auth-gate by itself — consumers protect the route
|
|
7
|
-
* via a `proxy.ts` that calls `verifyAdminSession` and redirects to
|
|
8
|
-
* `/admin/login` on failure.
|
|
9
|
-
*
|
|
10
|
-
* Keeping the layout free of auth state lets it stay synchronous and
|
|
11
|
-
* matches the locked contract (`children`-only).
|
|
12
|
-
*/
|
|
13
|
-
export function AdminLayout({ children }: { children: ReactNode }) {
|
|
14
|
-
return (
|
|
15
|
-
<div className="gm-admin-shell mx-auto max-w-5xl px-4 py-6">
|
|
16
|
-
<AdminNav />
|
|
17
|
-
{children}
|
|
18
|
-
</div>
|
|
19
|
-
);
|
|
20
|
-
}
|