@gigamusic/admin 0.3.0 → 1.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.
@@ -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
- }