@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.
- package/README.md +34 -0
- package/dist/cli.js +191 -0
- package/dist/cli.js.map +1 -0
- package/package.json +76 -0
- package/prisma/_typegen.prisma +29 -0
- package/prisma/link-page.prisma +54 -0
- package/src/api/auth.ts +34 -0
- package/src/api/handlers.ts +267 -0
- package/src/api/validation.ts +15 -0
- package/src/cli/index.ts +95 -0
- package/src/cli/paths.ts +11 -0
- package/src/cli/sync.ts +169 -0
- package/src/client.ts +10 -0
- package/src/components/LinkButton.tsx +20 -0
- package/src/components/LinkPageHeader.tsx +35 -0
- package/src/components/LinkPageView.tsx +64 -0
- package/src/components/admin/DeleteLinkPageButton.tsx +52 -0
- package/src/components/admin/LinkPageForm.tsx +490 -0
- package/src/components/admin/NewLinkPageForm.tsx +152 -0
- package/src/components/url-helpers.ts +18 -0
- package/src/index.ts +80 -0
- package/src/pages/AdminEditLinkPagePage.tsx +57 -0
- package/src/pages/AdminLinkPagesIndexPage.tsx +107 -0
- package/src/pages/AdminNewLinkPagePage.tsx +26 -0
- package/src/pages/PublicLinkPage.tsx +117 -0
- package/src/platforms/detect.ts +51 -0
- package/src/platforms/icon.tsx +108 -0
- package/src/queries/link-pages.ts +175 -0
- package/src/server.ts +22 -0
- package/src/slots-context.tsx +46 -0
- package/src/slots.ts +36 -0
- package/src/types.ts +65 -0
|
@@ -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
|
+
}
|