@gigamusic/admin 0.1.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 +18 -0
- package/package.json +69 -0
- package/src/client.ts +18 -0
- package/src/components/AdminLoginForm.tsx +63 -0
- package/src/components/AdminNav.tsx +50 -0
- package/src/components/ReleaseForm.tsx +556 -0
- package/src/handlers/auth.ts +58 -0
- package/src/handlers/links.ts +117 -0
- package/src/handlers/orders.ts +21 -0
- package/src/handlers/releases.ts +209 -0
- package/src/handlers/settings.ts +54 -0
- package/src/handlers/upload.ts +286 -0
- package/src/index.ts +24 -0
- package/src/lib/rate-limit.ts +31 -0
- package/src/lib/responses.ts +19 -0
- package/src/lib/session.ts +89 -0
- package/src/lib/types.ts +45 -0
- package/src/pages/AdminEditReleasePage.tsx +39 -0
- package/src/pages/AdminHomePage.tsx +38 -0
- package/src/pages/AdminLayout.tsx +20 -0
- package/src/pages/AdminLinksPage.tsx +187 -0
- package/src/pages/AdminLoginPage.tsx +11 -0
- package/src/pages/AdminNewReleasePage.tsx +12 -0
- package/src/pages/AdminOrdersPage.tsx +127 -0
- package/src/pages/AdminReleasesPage.tsx +87 -0
- package/src/pages/AdminSettingsPage.tsx +84 -0
- package/src/server.ts +36 -0
|
@@ -0,0 +1,556 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { verifyAdminPassword } from "@gigamusic/core";
|
|
2
|
+
import type { Queries } from "@gigamusic/db";
|
|
3
|
+
import {
|
|
4
|
+
clearAdminSessionCookie,
|
|
5
|
+
writeAdminSessionCookie,
|
|
6
|
+
} from "../lib/session";
|
|
7
|
+
import type { RouteHandler } from "../lib/types";
|
|
8
|
+
import { json, unauthorized } from "../lib/responses";
|
|
9
|
+
import { clientIp, consumeAdminLoginAttempt } from "../lib/rate-limit";
|
|
10
|
+
|
|
11
|
+
interface LoginDeps {
|
|
12
|
+
adminPasswordHash: string;
|
|
13
|
+
adminSessionSecret: string;
|
|
14
|
+
/** Optional — supply to gate brute-force login attempts. */
|
|
15
|
+
queries?: Queries;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the `POST /api/admin/auth` handler. Verifies the supplied password
|
|
20
|
+
* against the consumer's bcrypt hash, mints a session cookie on success, and
|
|
21
|
+
* rate-limits per-IP attempts when `queries` is provided.
|
|
22
|
+
*/
|
|
23
|
+
export function createAdminLoginHandler(deps: LoginDeps): RouteHandler {
|
|
24
|
+
return async (req) => {
|
|
25
|
+
if (deps.queries) {
|
|
26
|
+
const result = await consumeAdminLoginAttempt(deps.queries, clientIp(req));
|
|
27
|
+
if (!result.ok) {
|
|
28
|
+
return json(
|
|
29
|
+
{ error: "Too many attempts. Please try again later." },
|
|
30
|
+
{ status: 429 },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let password: string;
|
|
36
|
+
try {
|
|
37
|
+
const body = (await req.json()) as { password?: unknown };
|
|
38
|
+
password = typeof body.password === "string" ? body.password : "";
|
|
39
|
+
} catch {
|
|
40
|
+
return unauthorized();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!password) return unauthorized();
|
|
44
|
+
const ok = await verifyAdminPassword(password, deps.adminPasswordHash);
|
|
45
|
+
if (!ok) return unauthorized();
|
|
46
|
+
|
|
47
|
+
await writeAdminSessionCookie(deps.adminSessionSecret);
|
|
48
|
+
return json({ ok: true });
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build the `POST /api/admin/auth/logout` handler. Clears the session cookie. */
|
|
53
|
+
export function createAdminLogoutHandler(): RouteHandler {
|
|
54
|
+
return async () => {
|
|
55
|
+
await clearAdminSessionCookie();
|
|
56
|
+
return json({ ok: true });
|
|
57
|
+
};
|
|
58
|
+
}
|