@gigamusic/links 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,52 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+
6
+ interface Props {
7
+ pageId: number;
8
+ pageTitle: string;
9
+ redirectOnDelete?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Confirm-then-delete control for a single link page. POSTs DELETE to the
14
+ * admin route the consumer mounted from `createAdminLinkPageByIdHandlers`.
15
+ * Set `redirectOnDelete` when used from the edit page so the user lands back
16
+ * on the index after a successful delete.
17
+ */
18
+ export function DeleteLinkPageButton({
19
+ pageId,
20
+ pageTitle,
21
+ redirectOnDelete,
22
+ }: Props) {
23
+ const router = useRouter();
24
+ const [deleting, setDeleting] = useState(false);
25
+
26
+ async function handleDelete() {
27
+ if (!confirm(`Delete "${pageTitle}"? Its public URL will return 404.`)) return;
28
+ setDeleting(true);
29
+ const res = await fetch(`/api/admin/link-pages/${pageId}`, { method: "DELETE" });
30
+ if (!res.ok) {
31
+ setDeleting(false);
32
+ alert("Failed to delete link page");
33
+ return;
34
+ }
35
+ if (redirectOnDelete) {
36
+ router.push("/admin/link-pages");
37
+ } else {
38
+ router.refresh();
39
+ }
40
+ }
41
+
42
+ return (
43
+ <button
44
+ type="button"
45
+ onClick={handleDelete}
46
+ disabled={deleting}
47
+ className="text-sm rounded-md px-2.5 py-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
48
+ >
49
+ {deleting ? "Deleting..." : "Delete"}
50
+ </button>
51
+ );
52
+ }
@@ -0,0 +1,490 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { detectLinkPlatform } from "../../platforms/detect";
6
+ import { LinkPlatformIcon } from "../../platforms/icon";
7
+
8
+ export interface LinkPageFormItem {
9
+ id: number;
10
+ title: string;
11
+ url: string;
12
+ position: number;
13
+ isVisible: boolean;
14
+ }
15
+
16
+ export interface LinkPageFormReleaseOption {
17
+ id: number;
18
+ name: string;
19
+ slug: string;
20
+ coverImageUrl: string | null;
21
+ isPublished: boolean;
22
+ }
23
+
24
+ export interface LinkPageFormPage {
25
+ id: number;
26
+ slug: string;
27
+ title: string;
28
+ description: string | null;
29
+ coverImageUrl: string | null;
30
+ releaseId: number | null;
31
+ isPublished: boolean;
32
+ items: LinkPageFormItem[];
33
+ release: LinkPageFormReleaseOption | null;
34
+ }
35
+
36
+ interface Props {
37
+ page: LinkPageFormPage;
38
+ releases: LinkPageFormReleaseOption[];
39
+ baseUrl: string;
40
+ /**
41
+ * Optional cover upload hook. When provided, the file picker calls this and
42
+ * expects back a public URL. Returning `null` cancels the upload silently.
43
+ * Consumers typically wire this to a `/api/admin/upload/presign` route from
44
+ * `@gigamusic/admin`.
45
+ */
46
+ onUploadCover?: (file: File, pageId: number) => Promise<string | null>;
47
+ }
48
+
49
+ const DEBOUNCE_MS = 500;
50
+
51
+ /**
52
+ * Inline editor for a single link page. Every input debounces a PUT to the
53
+ * admin route mounted from `createAdminLinkPageByIdHandlers`; the item list
54
+ * uses `createAdminLinkPageItemsHandlers`. Drag-and-drop reorder is out of
55
+ * scope (per the plan) — up/down arrow buttons swap positions in place.
56
+ */
57
+ export function LinkPageForm({ page, releases, baseUrl, onUploadCover }: Props) {
58
+ const router = useRouter();
59
+
60
+ const [title, setTitle] = useState(page.title);
61
+ const [slug, setSlug] = useState(page.slug);
62
+ const [description, setDescription] = useState(page.description ?? "");
63
+ const [releaseId, setReleaseId] = useState<string>(
64
+ page.releaseId != null ? String(page.releaseId) : "",
65
+ );
66
+ const [coverImageUrl, setCoverImageUrl] = useState<string | null>(
67
+ page.coverImageUrl,
68
+ );
69
+ const [isPublished, setIsPublished] = useState(page.isPublished);
70
+ const [pageSaving, setPageSaving] = useState(false);
71
+ const [pageError, setPageError] = useState("");
72
+ const pageDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
73
+
74
+ const [items, setItems] = useState<LinkPageFormItem[]>(page.items);
75
+ const [savingItemIds, setSavingItemIds] = useState<Set<number>>(new Set());
76
+ const itemDebounceTimers = useRef<Map<number, ReturnType<typeof setTimeout>>>(
77
+ new Map(),
78
+ );
79
+ const [newTitle, setNewTitle] = useState("");
80
+ const [newUrl, setNewUrl] = useState("");
81
+ const [uploadingCover, setUploadingCover] = useState(false);
82
+
83
+ const selectedRelease = releaseId
84
+ ? releases.find((r) => String(r.id) === releaseId) ?? null
85
+ : null;
86
+ const resolvedCover = coverImageUrl ?? selectedRelease?.coverImageUrl ?? null;
87
+
88
+ async function savePage(updates: Record<string, unknown>) {
89
+ setPageSaving(true);
90
+ setPageError("");
91
+ const res = await fetch(`/api/admin/link-pages/${page.id}`, {
92
+ method: "PUT",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify(updates),
95
+ });
96
+ if (!res.ok) {
97
+ const data = await res.json().catch(() => ({}));
98
+ setPageError(data.error ?? "Failed to save");
99
+ }
100
+ setPageSaving(false);
101
+ router.refresh();
102
+ }
103
+
104
+ function debouncedSavePage(updates: Record<string, unknown>) {
105
+ if (pageDebounceRef.current) clearTimeout(pageDebounceRef.current);
106
+ pageDebounceRef.current = setTimeout(() => savePage(updates), DEBOUNCE_MS);
107
+ }
108
+
109
+ async function handleUploadCover(file: File) {
110
+ if (!onUploadCover) {
111
+ setPageError("Cover upload not configured");
112
+ return;
113
+ }
114
+ setUploadingCover(true);
115
+ try {
116
+ const publicUrl = await onUploadCover(file, page.id);
117
+ if (publicUrl) {
118
+ setCoverImageUrl(publicUrl);
119
+ await savePage({ coverImageUrl: publicUrl });
120
+ }
121
+ } catch (err) {
122
+ setPageError(err instanceof Error ? err.message : "Upload failed");
123
+ } finally {
124
+ setUploadingCover(false);
125
+ }
126
+ }
127
+
128
+ async function clearCoverOverride() {
129
+ setCoverImageUrl(null);
130
+ await savePage({ coverImageUrl: null });
131
+ }
132
+
133
+ async function copyPublicUrl() {
134
+ await navigator.clipboard.writeText(`${baseUrl}/links/${slug}`);
135
+ }
136
+
137
+ async function addItem() {
138
+ if (!newTitle || !newUrl) return;
139
+ const res = await fetch(`/api/admin/link-pages/${page.id}/items`, {
140
+ method: "POST",
141
+ headers: { "Content-Type": "application/json" },
142
+ body: JSON.stringify({ title: newTitle, url: newUrl }),
143
+ });
144
+ if (!res.ok) return;
145
+ const item = (await res.json()) as LinkPageFormItem;
146
+ setItems([...items, item]);
147
+ setNewTitle("");
148
+ setNewUrl("");
149
+ }
150
+
151
+ async function saveItem(item: LinkPageFormItem) {
152
+ setSavingItemIds((prev) => new Set(prev).add(item.id));
153
+ await fetch(`/api/admin/link-pages/${page.id}/items`, {
154
+ method: "PUT",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({
157
+ itemId: item.id,
158
+ title: item.title,
159
+ url: item.url,
160
+ position: item.position,
161
+ isVisible: item.isVisible,
162
+ }),
163
+ });
164
+ setSavingItemIds((prev) => {
165
+ const next = new Set(prev);
166
+ next.delete(item.id);
167
+ return next;
168
+ });
169
+ }
170
+
171
+ async function deleteItem(id: number) {
172
+ await fetch(
173
+ `/api/admin/link-pages/${page.id}/items?itemId=${encodeURIComponent(String(id))}`,
174
+ { method: "DELETE" },
175
+ );
176
+ setItems(items.filter((i) => i.id !== id));
177
+ }
178
+
179
+ function updateItemField(
180
+ id: number,
181
+ field: keyof LinkPageFormItem,
182
+ value: string | number | boolean,
183
+ ) {
184
+ const updated = items.map((i) =>
185
+ i.id === id ? { ...i, [field]: value } : i,
186
+ );
187
+ setItems(updated);
188
+
189
+ const existing = itemDebounceTimers.current.get(id);
190
+ if (existing) clearTimeout(existing);
191
+
192
+ const item = updated.find((i) => i.id === id);
193
+ if (!item) return;
194
+ itemDebounceTimers.current.set(
195
+ id,
196
+ setTimeout(() => {
197
+ itemDebounceTimers.current.delete(id);
198
+ saveItem(item);
199
+ }, DEBOUNCE_MS),
200
+ );
201
+ }
202
+
203
+ async function moveItem(index: number, direction: "up" | "down") {
204
+ const swap = direction === "up" ? index - 1 : index + 1;
205
+ if (swap < 0 || swap >= items.length) return;
206
+
207
+ const next = [...items];
208
+ const a = next[index];
209
+ const b = next[swap];
210
+ if (!a || !b) return;
211
+ const tempPos = a.position;
212
+ next[index] = { ...a, position: b.position };
213
+ next[swap] = { ...b, position: tempPos };
214
+ [next[index], next[swap]] = [next[swap], next[index]];
215
+ setItems(next);
216
+
217
+ await Promise.all([
218
+ fetch(`/api/admin/link-pages/${page.id}/items`, {
219
+ method: "PUT",
220
+ headers: { "Content-Type": "application/json" },
221
+ body: JSON.stringify({
222
+ itemId: next[index]!.id,
223
+ position: next[index]!.position,
224
+ }),
225
+ }),
226
+ fetch(`/api/admin/link-pages/${page.id}/items`, {
227
+ method: "PUT",
228
+ headers: { "Content-Type": "application/json" },
229
+ body: JSON.stringify({
230
+ itemId: next[swap]!.id,
231
+ position: next[swap]!.position,
232
+ }),
233
+ }),
234
+ ]);
235
+ }
236
+
237
+ return (
238
+ <div className="space-y-8 mt-4">
239
+ <div className="flex items-center justify-between rounded-lg border border-white/10 p-3">
240
+ <div className="text-sm">
241
+ <span className="text-muted-foreground">Public URL: </span>
242
+ <code className="text-foreground">/links/{slug}</code>
243
+ {pageSaving && (
244
+ <span className="ml-3 text-xs text-muted-foreground">Saving...</span>
245
+ )}
246
+ {pageError && (
247
+ <span className="ml-3 text-xs text-destructive">{pageError}</span>
248
+ )}
249
+ </div>
250
+ <div className="flex gap-2">
251
+ <button
252
+ type="button"
253
+ onClick={copyPublicUrl}
254
+ className="text-sm rounded-md px-2.5 py-1 border border-white/20"
255
+ >
256
+ Copy URL
257
+ </button>
258
+ <button
259
+ type="button"
260
+ onClick={() => {
261
+ const next = !isPublished;
262
+ setIsPublished(next);
263
+ savePage({ isPublished: next });
264
+ }}
265
+ className="text-sm rounded-md px-2.5 py-1"
266
+ >
267
+ {isPublished ? "Published" : "Draft"}
268
+ </button>
269
+ </div>
270
+ </div>
271
+
272
+ <div className="space-y-4 rounded-lg border border-white/10 p-4">
273
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
274
+ <label className="block space-y-1.5">
275
+ <span className="text-sm font-medium">Title</span>
276
+ <input
277
+ value={title}
278
+ onChange={(e) => {
279
+ setTitle(e.target.value);
280
+ debouncedSavePage({ title: e.target.value });
281
+ }}
282
+ className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
283
+ />
284
+ </label>
285
+ <label className="block space-y-1.5">
286
+ <span className="text-sm font-medium">Slug</span>
287
+ <input
288
+ value={slug}
289
+ onChange={(e) => {
290
+ setSlug(e.target.value);
291
+ debouncedSavePage({ slug: e.target.value });
292
+ }}
293
+ pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
294
+ className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
295
+ />
296
+ </label>
297
+ </div>
298
+
299
+ <label className="block space-y-1.5">
300
+ <span className="text-sm font-medium">Description</span>
301
+ <textarea
302
+ value={description}
303
+ onChange={(e) => {
304
+ setDescription(e.target.value);
305
+ debouncedSavePage({ description: e.target.value || null });
306
+ }}
307
+ rows={3}
308
+ className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
309
+ />
310
+ </label>
311
+
312
+ <label className="block space-y-1.5">
313
+ <span className="text-sm font-medium">Release</span>
314
+ <select
315
+ value={releaseId}
316
+ onChange={(e) => {
317
+ setReleaseId(e.target.value);
318
+ savePage({
319
+ releaseId: e.target.value ? Number(e.target.value) : null,
320
+ });
321
+ }}
322
+ className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
323
+ >
324
+ <option value="">— None —</option>
325
+ {releases.map((r) => (
326
+ <option key={r.id} value={r.id}>
327
+ {r.name}
328
+ {!r.isPublished && " (draft)"}
329
+ </option>
330
+ ))}
331
+ </select>
332
+ {selectedRelease && !selectedRelease.isPublished && (
333
+ <span className="block text-xs text-destructive">
334
+ This release is a draft and is hidden from the storefront. Publish
335
+ it before sharing this link page.
336
+ </span>
337
+ )}
338
+ </label>
339
+
340
+ <div className="space-y-1.5">
341
+ <span className="block text-sm font-medium">Cover image</span>
342
+ <div className="flex items-start gap-4">
343
+ {resolvedCover ? (
344
+ <div className="w-24 h-24 rounded-md overflow-hidden ring-1 ring-white/20 shrink-0">
345
+ {/* eslint-disable-next-line @next/next/no-img-element */}
346
+ <img
347
+ src={resolvedCover}
348
+ alt="Cover"
349
+ width={200}
350
+ height={200}
351
+ className="w-full h-full object-cover"
352
+ />
353
+ </div>
354
+ ) : (
355
+ <div className="w-24 h-24 rounded-md border border-dashed border-white/20 flex items-center justify-center text-xs text-muted-foreground shrink-0">
356
+ No cover
357
+ </div>
358
+ )}
359
+ <div className="flex flex-col gap-2 text-sm">
360
+ <input
361
+ type="file"
362
+ accept="image/*"
363
+ onChange={(e) => {
364
+ const file = e.target.files?.[0];
365
+ if (file) handleUploadCover(file);
366
+ }}
367
+ disabled={uploadingCover || !onUploadCover}
368
+ />
369
+ {coverImageUrl && (
370
+ <button
371
+ type="button"
372
+ onClick={clearCoverOverride}
373
+ className="text-sm self-start text-muted-foreground hover:underline"
374
+ >
375
+ Clear override
376
+ </button>
377
+ )}
378
+ <p className="text-xs text-muted-foreground">
379
+ {coverImageUrl
380
+ ? "Custom override active."
381
+ : selectedRelease?.coverImageUrl
382
+ ? "Using release cover. Upload to override."
383
+ : "Upload a cover or link a release."}
384
+ </p>
385
+ </div>
386
+ </div>
387
+ </div>
388
+ </div>
389
+
390
+ <div className="space-y-3">
391
+ <h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
392
+ Links on this page
393
+ </h2>
394
+ {items.map((item, index) => {
395
+ const platform = detectLinkPlatform(item.url);
396
+ return (
397
+ <div
398
+ key={item.id}
399
+ className="flex items-start gap-3 rounded-lg border border-white/10 p-3"
400
+ >
401
+ <div className="flex flex-col gap-0.5 shrink-0">
402
+ <button
403
+ type="button"
404
+ className="h-6 w-6 text-xs"
405
+ disabled={index === 0}
406
+ onClick={() => moveItem(index, "up")}
407
+ >
408
+
409
+ </button>
410
+ <button
411
+ type="button"
412
+ className="h-6 w-6 text-xs"
413
+ disabled={index === items.length - 1}
414
+ onClick={() => moveItem(index, "down")}
415
+ >
416
+
417
+ </button>
418
+ </div>
419
+ <div className="w-6 h-6 flex items-center justify-center text-muted-foreground shrink-0 mt-1">
420
+ {platform ? <LinkPlatformIcon platform={platform} size={20} /> : "—"}
421
+ </div>
422
+ <div className="flex-1 min-w-0 flex flex-col sm:flex-row sm:items-center gap-2">
423
+ <input
424
+ value={item.title}
425
+ onChange={(e) =>
426
+ updateItemField(item.id, "title", e.target.value)
427
+ }
428
+ placeholder="Title"
429
+ className="w-full sm:flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm"
430
+ />
431
+ <input
432
+ value={item.url}
433
+ onChange={(e) =>
434
+ updateItemField(item.id, "url", e.target.value)
435
+ }
436
+ placeholder="https://..."
437
+ className="w-full sm:flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm"
438
+ />
439
+ <div className="flex flex-wrap items-center gap-2">
440
+ <button
441
+ type="button"
442
+ onClick={() =>
443
+ updateItemField(item.id, "isVisible", !item.isVisible)
444
+ }
445
+ className="text-sm rounded-md px-2 py-1"
446
+ >
447
+ {item.isVisible ? "Visible" : "Hidden"}
448
+ </button>
449
+ {savingItemIds.has(item.id) && (
450
+ <span className="text-xs text-muted-foreground">Saving...</span>
451
+ )}
452
+ <button
453
+ type="button"
454
+ onClick={() => deleteItem(item.id)}
455
+ className="text-sm rounded-md px-2 py-1 text-destructive"
456
+ >
457
+ Delete
458
+ </button>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ );
463
+ })}
464
+
465
+ <div className="flex flex-col sm:flex-row sm:items-center gap-2 rounded-lg border border-dashed border-white/20 p-3">
466
+ <input
467
+ value={newTitle}
468
+ onChange={(e) => setNewTitle(e.target.value)}
469
+ placeholder="Spotify, Apple Music, etc."
470
+ className="w-full sm:flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm"
471
+ />
472
+ <input
473
+ value={newUrl}
474
+ onChange={(e) => setNewUrl(e.target.value)}
475
+ placeholder="https://..."
476
+ className="w-full sm:flex-1 min-w-0 rounded-md border border-input bg-background px-3 py-2 text-sm"
477
+ />
478
+ <button
479
+ type="button"
480
+ onClick={addItem}
481
+ disabled={!newTitle || !newUrl}
482
+ className="rounded-md px-3 py-2 bg-[var(--gm-color-primary)]/90 text-black text-sm disabled:opacity-50"
483
+ >
484
+ Add Link
485
+ </button>
486
+ </div>
487
+ </div>
488
+ </div>
489
+ );
490
+ }
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { slugify } from "@gigamusic/core";
6
+
7
+ export interface NewLinkPageReleaseOption {
8
+ id: number;
9
+ name: string;
10
+ slug: string;
11
+ isPublished: boolean;
12
+ }
13
+
14
+ interface Props {
15
+ releases: NewLinkPageReleaseOption[];
16
+ initialRelease?: NewLinkPageReleaseOption;
17
+ }
18
+
19
+ /**
20
+ * Minimal "create new link page" form. POSTs to the route mounted from
21
+ * `createAdminLinkPagesHandlers`. On success, redirects to the edit page so
22
+ * the user can flesh out the items.
23
+ *
24
+ * The slug field auto-fills from the title until the user touches it.
25
+ */
26
+ export function NewLinkPageForm({ releases, initialRelease }: Props) {
27
+ const router = useRouter();
28
+ const [title, setTitle] = useState(initialRelease?.name ?? "");
29
+ const [slug, setSlug] = useState(initialRelease?.slug ?? "");
30
+ const [slugTouched, setSlugTouched] = useState(Boolean(initialRelease));
31
+ const [description, setDescription] = useState("");
32
+ const [releaseId, setReleaseId] = useState<string>(
33
+ initialRelease ? String(initialRelease.id) : "",
34
+ );
35
+ const [saving, setSaving] = useState(false);
36
+ const [error, setError] = useState("");
37
+
38
+ function onTitleChange(value: string) {
39
+ setTitle(value);
40
+ if (!slugTouched) setSlug(slugify(value));
41
+ }
42
+
43
+ async function handleSubmit(e: React.FormEvent) {
44
+ e.preventDefault();
45
+ setSaving(true);
46
+ setError("");
47
+ const res = await fetch("/api/admin/link-pages", {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify({
51
+ title,
52
+ slug,
53
+ description: description || null,
54
+ releaseId: releaseId ? Number(releaseId) : null,
55
+ }),
56
+ });
57
+ const data = await res.json();
58
+ if (!res.ok) {
59
+ setError(data.error ?? "Failed to create link page");
60
+ setSaving(false);
61
+ return;
62
+ }
63
+ router.push(`/admin/link-pages/${data.id}/edit`);
64
+ }
65
+
66
+ const picked = releaseId ? releases.find((r) => String(r.id) === releaseId) : null;
67
+
68
+ return (
69
+ <form onSubmit={handleSubmit} className="space-y-4 mt-4">
70
+ <label className="block space-y-1.5">
71
+ <span className="text-sm font-medium">Title</span>
72
+ <input
73
+ value={title}
74
+ onChange={(e) => onTitleChange(e.target.value)}
75
+ placeholder="Summer Release"
76
+ required
77
+ className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
78
+ />
79
+ </label>
80
+
81
+ <label className="block space-y-1.5">
82
+ <span className="text-sm font-medium">Slug</span>
83
+ <input
84
+ value={slug}
85
+ onChange={(e) => {
86
+ setSlug(e.target.value);
87
+ setSlugTouched(true);
88
+ }}
89
+ placeholder="summer-release"
90
+ required
91
+ pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$"
92
+ className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
93
+ />
94
+ <span className="block text-xs text-muted-foreground">
95
+ Public URL: <code>/links/{slug || "your-slug"}</code>
96
+ </span>
97
+ </label>
98
+
99
+ <label className="block space-y-1.5">
100
+ <span className="text-sm font-medium">Release (optional)</span>
101
+ <select
102
+ value={releaseId}
103
+ onChange={(e) => setReleaseId(e.target.value)}
104
+ className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
105
+ >
106
+ <option value="">— None —</option>
107
+ {releases.map((r) => (
108
+ <option key={r.id} value={r.id}>
109
+ {r.name}
110
+ {!r.isPublished && " (draft)"}
111
+ </option>
112
+ ))}
113
+ </select>
114
+ {picked && !picked.isPublished && (
115
+ <span className="block text-xs text-destructive">
116
+ This release is a draft and is hidden from the storefront. Publish
117
+ it before sharing this link page.
118
+ </span>
119
+ )}
120
+ </label>
121
+
122
+ <label className="block space-y-1.5">
123
+ <span className="text-sm font-medium">Description (optional)</span>
124
+ <textarea
125
+ value={description}
126
+ onChange={(e) => setDescription(e.target.value)}
127
+ rows={3}
128
+ className="block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
129
+ />
130
+ </label>
131
+
132
+ {error && <p className="text-sm text-destructive">{error}</p>}
133
+
134
+ <div className="flex gap-2">
135
+ <button
136
+ type="submit"
137
+ disabled={saving || !title || !slug}
138
+ className="rounded-md px-3 py-2 bg-[var(--gm-color-primary)]/90 text-black text-sm disabled:opacity-50"
139
+ >
140
+ {saving ? "Creating..." : "Create"}
141
+ </button>
142
+ <button
143
+ type="button"
144
+ onClick={() => router.push("/admin/link-pages")}
145
+ className="rounded-md px-3 py-2 border border-white/20 text-sm"
146
+ >
147
+ Cancel
148
+ </button>
149
+ </div>
150
+ </form>
151
+ );
152
+ }