@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.
@@ -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
+ }