@gigamusic/links 0.3.0 → 2.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.
- package/README.md +49 -17
- package/package.json +16 -31
- package/src/index.ts +3 -52
- package/src/platforms/detect.ts +10 -3
- package/src/queries/link-pages.ts +72 -77
- package/src/schema/index.ts +2 -0
- package/src/schema/link-pages.ts +41 -0
- package/src/schema/relations.ts +12 -0
- package/src/server.ts +1 -7
- package/src/types.ts +5 -23
- package/dist/cli.js +0 -191
- package/dist/cli.js.map +0 -1
- package/prisma/_typegen.prisma +0 -29
- package/prisma/link-page.prisma +0 -54
- package/src/cli/index.ts +0 -95
- package/src/cli/paths.ts +0 -11
- package/src/cli/sync.ts +0 -169
- package/src/client.ts +0 -10
- package/src/components/LinkButton.tsx +0 -20
- package/src/components/LinkPageHeader.tsx +0 -35
- package/src/components/LinkPageView.tsx +0 -64
- package/src/components/admin/DeleteLinkPageButton.tsx +0 -52
- package/src/components/admin/LinkPageForm.tsx +0 -490
- package/src/components/admin/NewLinkPageForm.tsx +0 -152
- package/src/components/url-helpers.ts +0 -18
- package/src/pages/AdminEditLinkPagePage.tsx +0 -57
- package/src/pages/AdminLinkPagesIndexPage.tsx +0 -107
- package/src/pages/AdminNewLinkPagePage.tsx +0 -26
- package/src/pages/PublicLinkPage.tsx +0 -117
- package/src/slots-context.tsx +0 -46
- package/src/slots.ts +0 -36
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { detectLinkPlatform } from "../platforms/detect";
|
|
4
|
-
import { isInternalUrl } from "./url-helpers";
|
|
5
|
-
import { useLinksSlot } from "../slots-context";
|
|
6
|
-
import type { LinkPageWithItems } from "../types";
|
|
7
|
-
import type { LinksSlots } from "../slots";
|
|
8
|
-
|
|
9
|
-
interface LinkPageViewProps {
|
|
10
|
-
page: LinkPageWithItems;
|
|
11
|
-
/**
|
|
12
|
-
* Per-call slot overrides. Most consumers register slots once on
|
|
13
|
-
* `<LinksSlotsProvider>` and omit this prop. Keys not present here fall
|
|
14
|
-
* back to the provider, then to the package defaults.
|
|
15
|
-
*/
|
|
16
|
-
slots?: Partial<LinksSlots>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Public-facing render of a `LinkPage`. Renders the resolved cover, header,
|
|
21
|
-
* and a vertical list of link buttons. Slots:
|
|
22
|
-
* - `LinkButton` — the rendered pill for each item.
|
|
23
|
-
* - `LinkPageHeader` — the title block above the buttons.
|
|
24
|
-
*
|
|
25
|
-
* Cover image resolution: explicit `page.coverImageUrl` override → linked
|
|
26
|
-
* release's `coverImageUrl` (only when the release is published, to avoid
|
|
27
|
-
* leaking a draft's art) → `null`.
|
|
28
|
-
*/
|
|
29
|
-
export function LinkPageView({ page, slots }: LinkPageViewProps) {
|
|
30
|
-
const providerHeader = useLinksSlot("LinkPageHeader");
|
|
31
|
-
const providerButton = useLinksSlot("LinkButton");
|
|
32
|
-
const Header = slots?.LinkPageHeader ?? providerHeader;
|
|
33
|
-
const LinkButton = slots?.LinkButton ?? providerButton;
|
|
34
|
-
|
|
35
|
-
const releaseCover = page.release?.isPublished
|
|
36
|
-
? page.release.coverImageUrl
|
|
37
|
-
: null;
|
|
38
|
-
const coverSrc = page.coverImageUrl ?? releaseCover ?? null;
|
|
39
|
-
const cover = coverSrc ? { src: coverSrc, alt: page.title } : null;
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<div className="min-h-dvh flex items-start justify-center px-4 py-10">
|
|
43
|
-
<div className="w-full max-w-md flex flex-col items-center gap-5">
|
|
44
|
-
<Header title={page.title} description={page.description} cover={cover} />
|
|
45
|
-
|
|
46
|
-
<div className="w-full flex flex-col gap-3 mt-2">
|
|
47
|
-
{page.items.map((item) => {
|
|
48
|
-
const platform = detectLinkPlatform(item.url);
|
|
49
|
-
const internal = isInternalUrl(item.url);
|
|
50
|
-
return (
|
|
51
|
-
<LinkButton
|
|
52
|
-
key={item.id}
|
|
53
|
-
href={item.url}
|
|
54
|
-
title={item.title}
|
|
55
|
-
platform={platform}
|
|
56
|
-
internal={internal}
|
|
57
|
-
/>
|
|
58
|
-
);
|
|
59
|
-
})}
|
|
60
|
-
</div>
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,490 +0,0 @@
|
|
|
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
|
-
}
|