@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,267 @@
1
+ import { revalidateTag } from "next/cache";
2
+ import type { LinkPageQueries } from "../types";
3
+ import { verifyAdminSession } from "./auth";
4
+ import {
5
+ INVALID_SLUG_MESSAGE,
6
+ RESERVED_SLUGS,
7
+ RESERVED_SLUG_MESSAGE,
8
+ SLUG_PATTERN,
9
+ isUniqueConstraintError,
10
+ } from "./validation";
11
+
12
+ /** Cache tag flipped by every mutation so consumers can `revalidateTag` once. */
13
+ export const LINK_PAGES_TAG = "link-pages";
14
+
15
+ /**
16
+ * Loose `Request`-handler shape compatible with Next.js App Router route
17
+ * files. The `ctx.params` is generic so handlers for both dynamic-id and
18
+ * static routes can share the type.
19
+ */
20
+ export type RouteHandler<Params = Record<string, string>> = (
21
+ req: Request,
22
+ ctx: { params: Promise<Params> },
23
+ ) => Promise<Response> | Response;
24
+
25
+ export interface LinkPagesAdminDeps {
26
+ queries: LinkPageQueries;
27
+ adminSessionSecret: string;
28
+ }
29
+
30
+ function json(body: unknown, init?: ResponseInit): Response {
31
+ return new Response(JSON.stringify(body), {
32
+ ...init,
33
+ headers: {
34
+ "content-type": "application/json",
35
+ ...(init?.headers ?? {}),
36
+ },
37
+ });
38
+ }
39
+
40
+ async function requireAdmin(
41
+ req: Request,
42
+ secret: string,
43
+ ): Promise<Response | null> {
44
+ if (await verifyAdminSession(req, secret)) return null;
45
+ return json({ error: "Unauthorized" }, { status: 401 });
46
+ }
47
+
48
+ interface LinkPageBody {
49
+ title?: string;
50
+ slug?: string;
51
+ description?: string | null;
52
+ releaseId?: number | null;
53
+ coverImageUrl?: string | null;
54
+ isPublished?: boolean;
55
+ }
56
+
57
+ function validateSlug(slug: string): { ok: true } | { ok: false; res: Response } {
58
+ if (!SLUG_PATTERN.test(slug)) {
59
+ return { ok: false, res: json({ error: INVALID_SLUG_MESSAGE }, { status: 400 }) };
60
+ }
61
+ if (RESERVED_SLUGS.has(slug)) {
62
+ return { ok: false, res: json({ error: RESERVED_SLUG_MESSAGE }, { status: 400 }) };
63
+ }
64
+ return { ok: true };
65
+ }
66
+
67
+ /**
68
+ * `app/api/admin/link-pages/route.ts` handlers — list + create.
69
+ */
70
+ export function createAdminLinkPagesHandlers(deps: LinkPagesAdminDeps): {
71
+ GET: RouteHandler<Record<string, never>>;
72
+ POST: RouteHandler<Record<string, never>>;
73
+ } {
74
+ return {
75
+ GET: async (req) => {
76
+ const unauth = await requireAdmin(req, deps.adminSessionSecret);
77
+ if (unauth) return unauth;
78
+ const pages = await deps.queries.listAllLinkPages();
79
+ return json(pages);
80
+ },
81
+
82
+ POST: async (req) => {
83
+ const unauth = await requireAdmin(req, deps.adminSessionSecret);
84
+ if (unauth) return unauth;
85
+ const body = (await req.json()) as LinkPageBody;
86
+ const title = body.title?.trim();
87
+ const slug = body.slug?.trim().toLowerCase();
88
+
89
+ if (!title) return json({ error: "Title is required" }, { status: 400 });
90
+ if (!slug) return json({ error: INVALID_SLUG_MESSAGE }, { status: 400 });
91
+ const slugCheck = validateSlug(slug);
92
+ if (!slugCheck.ok) return slugCheck.res;
93
+
94
+ try {
95
+ const page = await deps.queries.createLinkPage({
96
+ title,
97
+ slug,
98
+ description: body.description ?? null,
99
+ releaseId: body.releaseId ?? null,
100
+ coverImageUrl: body.coverImageUrl ?? null,
101
+ isPublished: body.isPublished ?? true,
102
+ });
103
+ revalidateTag(LINK_PAGES_TAG, "max");
104
+ return json(page, { status: 201 });
105
+ } catch (err) {
106
+ if (isUniqueConstraintError(err)) {
107
+ return json({ error: "Slug already exists" }, { status: 400 });
108
+ }
109
+ throw err;
110
+ }
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * `app/api/admin/link-pages/[id]/route.ts` handlers — single-page CRUD.
117
+ */
118
+ export function createAdminLinkPageByIdHandlers(deps: LinkPagesAdminDeps): {
119
+ GET: RouteHandler<{ id: string }>;
120
+ PUT: RouteHandler<{ id: string }>;
121
+ DELETE: RouteHandler<{ id: string }>;
122
+ } {
123
+ return {
124
+ GET: async (req, { params }) => {
125
+ const unauth = await requireAdmin(req, deps.adminSessionSecret);
126
+ if (unauth) return unauth;
127
+ const { id: idParam } = await params;
128
+ const id = Number(idParam);
129
+ if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
130
+ const page = await deps.queries.getLinkPageById(id);
131
+ if (!page) return json({ error: "Not found" }, { status: 404 });
132
+ return json(page);
133
+ },
134
+
135
+ PUT: async (req, { params }) => {
136
+ const unauth = await requireAdmin(req, deps.adminSessionSecret);
137
+ if (unauth) return unauth;
138
+ const { id: idParam } = await params;
139
+ const id = Number(idParam);
140
+ if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
141
+ const body = (await req.json()) as LinkPageBody;
142
+
143
+ const update: LinkPageBody = {};
144
+ if (body.title !== undefined) {
145
+ const title = body.title.trim();
146
+ if (!title) return json({ error: "Title is required" }, { status: 400 });
147
+ update.title = title;
148
+ }
149
+ if (body.slug !== undefined) {
150
+ const slug = body.slug.trim().toLowerCase();
151
+ const slugCheck = validateSlug(slug);
152
+ if (!slugCheck.ok) return slugCheck.res;
153
+ update.slug = slug;
154
+ }
155
+ if (body.description !== undefined) update.description = body.description;
156
+ if (body.releaseId !== undefined) update.releaseId = body.releaseId;
157
+ if (body.coverImageUrl !== undefined)
158
+ update.coverImageUrl = body.coverImageUrl;
159
+ if (body.isPublished !== undefined) update.isPublished = body.isPublished;
160
+
161
+ try {
162
+ const page = await deps.queries.updateLinkPage(id, update);
163
+ revalidateTag(LINK_PAGES_TAG, "max");
164
+ return json(page);
165
+ } catch (err) {
166
+ if (isUniqueConstraintError(err)) {
167
+ return json({ error: "Slug already exists" }, { status: 400 });
168
+ }
169
+ throw err;
170
+ }
171
+ },
172
+
173
+ DELETE: async (req, { params }) => {
174
+ const unauth = await requireAdmin(req, deps.adminSessionSecret);
175
+ if (unauth) return unauth;
176
+ const { id: idParam } = await params;
177
+ const id = Number(idParam);
178
+ if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
179
+ await deps.queries.deleteLinkPage(id);
180
+ revalidateTag(LINK_PAGES_TAG, "max");
181
+ return json({ ok: true });
182
+ },
183
+ };
184
+ }
185
+
186
+ interface LinkPageItemBody {
187
+ itemId?: number;
188
+ title?: string;
189
+ url?: string;
190
+ position?: number;
191
+ isVisible?: boolean;
192
+ }
193
+
194
+ /**
195
+ * `app/api/admin/link-pages/[id]/items/route.ts` handlers — item CRUD.
196
+ *
197
+ * POST creates a new item at the bottom of the list.
198
+ * PUT updates a single item by `itemId` in the JSON body.
199
+ * DELETE removes an item by `itemId` in the query string.
200
+ */
201
+ export function createAdminLinkPageItemsHandlers(
202
+ deps: LinkPagesAdminDeps,
203
+ ): {
204
+ POST: RouteHandler<{ id: string }>;
205
+ PUT: RouteHandler<{ id: string }>;
206
+ DELETE: RouteHandler<{ id: string }>;
207
+ } {
208
+ return {
209
+ POST: async (req, { params }) => {
210
+ const unauth = await requireAdmin(req, deps.adminSessionSecret);
211
+ if (unauth) return unauth;
212
+ const { id: pageIdParam } = await params;
213
+ const pageId = Number(pageIdParam);
214
+ if (!Number.isFinite(pageId)) {
215
+ return json({ error: "Not found" }, { status: 404 });
216
+ }
217
+ const { title, url } = (await req.json()) as { title?: string; url?: string };
218
+ if (!title?.trim() || !url?.trim()) {
219
+ return json({ error: "Title and URL are required" }, { status: 400 });
220
+ }
221
+ const item = await deps.queries.addLinkPageItem(pageId, {
222
+ title: title.trim(),
223
+ url: url.trim(),
224
+ });
225
+ revalidateTag(LINK_PAGES_TAG, "max");
226
+ return json(item, { status: 201 });
227
+ },
228
+
229
+ PUT: async (req) => {
230
+ const unauth = await requireAdmin(req, deps.adminSessionSecret);
231
+ if (unauth) return unauth;
232
+ const body = (await req.json()) as LinkPageItemBody;
233
+ if (body.itemId == null) {
234
+ return json({ error: "itemId is required" }, { status: 400 });
235
+ }
236
+ const itemId = Number(body.itemId);
237
+ if (!Number.isFinite(itemId)) {
238
+ return json({ error: "itemId is required" }, { status: 400 });
239
+ }
240
+ const item = await deps.queries.updateLinkPageItem(itemId, {
241
+ title: body.title,
242
+ url: body.url,
243
+ position: body.position,
244
+ isVisible: body.isVisible,
245
+ });
246
+ revalidateTag(LINK_PAGES_TAG, "max");
247
+ return json(item);
248
+ },
249
+
250
+ DELETE: async (req) => {
251
+ const unauth = await requireAdmin(req, deps.adminSessionSecret);
252
+ if (unauth) return unauth;
253
+ const { searchParams } = new URL(req.url);
254
+ const itemIdRaw = searchParams.get("itemId");
255
+ if (!itemIdRaw) {
256
+ return json({ error: "itemId is required" }, { status: 400 });
257
+ }
258
+ const itemId = Number(itemIdRaw);
259
+ if (!Number.isFinite(itemId)) {
260
+ return json({ error: "itemId is required" }, { status: 400 });
261
+ }
262
+ await deps.queries.deleteLinkPageItem(itemId);
263
+ revalidateTag(LINK_PAGES_TAG, "max");
264
+ return json({ ok: true });
265
+ },
266
+ };
267
+ }
@@ -0,0 +1,15 @@
1
+ export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2
+ export const RESERVED_SLUGS = new Set(["new", "edit", "admin", "api"]);
3
+
4
+ export const INVALID_SLUG_MESSAGE =
5
+ "Slug must be lowercase letters, numbers, and dashes";
6
+ export const RESERVED_SLUG_MESSAGE = "Slug is reserved";
7
+
8
+ /** True for Prisma's "unique constraint violated" code (P2002). */
9
+ export function isUniqueConstraintError(err: unknown): boolean {
10
+ return (
11
+ typeof err === "object" &&
12
+ err !== null &&
13
+ (err as { code?: string }).code === "P2002"
14
+ );
15
+ }
@@ -0,0 +1,95 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { fragmentsDir } from "./paths";
4
+ import { doctorFragments, syncFragments } from "./sync";
5
+
6
+ interface ParsedArgs {
7
+ command: "sync" | "doctor" | "help" | "version";
8
+ target?: string;
9
+ dryRun: boolean;
10
+ }
11
+
12
+ function parseArgs(argv: string[]): ParsedArgs {
13
+ const [command = "help", ...rest] = argv;
14
+ const parsed: ParsedArgs = {
15
+ command: (["sync", "doctor", "help", "version"].includes(command)
16
+ ? command
17
+ : "help") as ParsedArgs["command"],
18
+ dryRun: false,
19
+ };
20
+ for (let i = 0; i < rest.length; i++) {
21
+ const arg = rest[i];
22
+ if (arg === "--dry-run") parsed.dryRun = true;
23
+ else if (arg === "--target") parsed.target = rest[++i];
24
+ else if (arg?.startsWith("--target=")) parsed.target = arg.slice("--target=".length);
25
+ }
26
+ return parsed;
27
+ }
28
+
29
+ function readPackageVersion(metaUrl: string): string {
30
+ try {
31
+ const pkgPath = resolve(fragmentsDir(metaUrl), "..", "package.json");
32
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
33
+ return pkg.version ?? "0.0.0";
34
+ } catch {
35
+ return "0.0.0";
36
+ }
37
+ }
38
+
39
+ function printHelp() {
40
+ console.log(`gigamusic-links — Prisma fragment sync for @gigamusic/links
41
+
42
+ Usage:
43
+ gigamusic-links sync [--target <prismaSchemaFolder>] [--dry-run]
44
+ gigamusic-links doctor [--target <prismaSchemaFolder>]
45
+ gigamusic-links version
46
+ gigamusic-links help
47
+
48
+ Fragments are written with a "20-" prefix so they sort AFTER @gigamusic/db's
49
+ "10-" fragments and BEFORE consumer-owned "99-" files. Default --target is
50
+ ./prisma/schema (Prisma 6 prismaSchemaFolder layout).
51
+ `);
52
+ }
53
+
54
+ export async function main(argv: string[], metaUrl: string): Promise<number> {
55
+ const args = parseArgs(argv);
56
+ const sourceDir = fragmentsDir(metaUrl);
57
+ const version = readPackageVersion(metaUrl);
58
+
59
+ if (args.command === "help") {
60
+ printHelp();
61
+ return 0;
62
+ }
63
+ if (args.command === "version") {
64
+ console.log(version);
65
+ return 0;
66
+ }
67
+ if (args.command === "sync") {
68
+ syncFragments(
69
+ { target: args.target, dryRun: args.dryRun },
70
+ { sourceDir, version },
71
+ );
72
+ return 0;
73
+ }
74
+ if (args.command === "doctor") {
75
+ const result = doctorFragments({ target: args.target }, { sourceDir, version });
76
+ if (!result.ok) {
77
+ console.error(
78
+ `gigamusic-links doctor: ${result.stale.length} stale, ${result.missing.length} missing`,
79
+ );
80
+ return 1;
81
+ }
82
+ console.log("gigamusic-links doctor: all fragments are up to date.");
83
+ return 0;
84
+ }
85
+ printHelp();
86
+ return 1;
87
+ }
88
+
89
+ main(process.argv.slice(2), import.meta.url).then(
90
+ (code) => process.exit(code),
91
+ (err) => {
92
+ console.error(err);
93
+ process.exit(1);
94
+ },
95
+ );
@@ -0,0 +1,11 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ /**
5
+ * Locate the shipped `prisma/` fragments directory. At runtime the CLI bundle
6
+ * lives at `<pkg>/dist/cli.js`; the fragments live at `<pkg>/prisma/`.
7
+ */
8
+ export function fragmentsDir(metaUrl: string): string {
9
+ const here = dirname(fileURLToPath(metaUrl));
10
+ return resolve(here, "..", "prisma");
11
+ }
@@ -0,0 +1,169 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+
11
+ export interface SyncOptions {
12
+ /** Where the consumer's prisma schema folder lives. Defaults to `<cwd>/prisma/schema`. */
13
+ target?: string;
14
+ /** When true, log planned writes without touching the filesystem. */
15
+ dryRun?: boolean;
16
+ /** Override for testing — defaults to process.cwd(). */
17
+ cwd?: string;
18
+ }
19
+
20
+ export interface SyncResult {
21
+ written: string[];
22
+ skipped: { file: string; reason: string }[];
23
+ }
24
+
25
+ export interface SyncDeps {
26
+ sourceDir: string;
27
+ version: string;
28
+ today?: string;
29
+ logger?: { info: (msg: string) => void; warn: (msg: string) => void };
30
+ }
31
+
32
+ /**
33
+ * Sync prefix — `20-` orders after `10-` (gigamusic/db) and before `99-`
34
+ * (consumer-owned). Keeping the prefix shared with @gigamusic/db's sort
35
+ * convention so consumers don't need to learn a new rule.
36
+ */
37
+ const SYNC_PREFIX = "20-";
38
+
39
+ const VERSION_RE = /AUTOGENERATED by @gigamusic\/links@([^\s]+) on (\d{4}-\d{2}-\d{2})/;
40
+
41
+ export function listFragmentFiles(sourceDir: string): string[] {
42
+ return readdirSync(sourceDir)
43
+ .filter((f) => f.endsWith(".prisma") && !f.startsWith("_"))
44
+ .sort();
45
+ }
46
+
47
+ function buildHeader(version: string, today: string): string {
48
+ return `// AUTOGENERATED by @gigamusic/links@${version} on ${today} — do not edit; re-run \`pnpm gigamusic-links sync\`\n`;
49
+ }
50
+
51
+ function readExistingVersion(filePath: string): string | null {
52
+ if (!existsSync(filePath)) return null;
53
+ const head = readFileSync(filePath, "utf8").split("\n", 2).join("\n");
54
+ const match = VERSION_RE.exec(head);
55
+ return match?.[1] ?? null;
56
+ }
57
+
58
+ /**
59
+ * Semver-ish comparison — accepts `x.y.z` or pre-release suffixes. Returns
60
+ * 1/0/-1 like `Array.prototype.sort`. Workspace `workspace:*` and similar
61
+ * non-numeric strings compare equal so we never refuse to overwrite during
62
+ * local dev.
63
+ */
64
+ export function compareVersions(a: string, b: string): number {
65
+ if (a === b) return 0;
66
+ const aParts = a.split(/[.-]/);
67
+ const bParts = b.split(/[.-]/);
68
+ const len = Math.max(aParts.length, bParts.length);
69
+ for (let i = 0; i < len; i++) {
70
+ const ai = parseInt(aParts[i] ?? "0", 10);
71
+ const bi = parseInt(bParts[i] ?? "0", 10);
72
+ if (Number.isNaN(ai) || Number.isNaN(bi)) {
73
+ const as = aParts[i] ?? "";
74
+ const bs = bParts[i] ?? "";
75
+ if (as === bs) continue;
76
+ return as < bs ? -1 : 1;
77
+ }
78
+ if (ai !== bi) return ai < bi ? -1 : 1;
79
+ }
80
+ return 0;
81
+ }
82
+
83
+ export function syncFragments(opts: SyncOptions, deps: SyncDeps): SyncResult {
84
+ const cwd = opts.cwd ?? process.cwd();
85
+ const target = resolve(cwd, opts.target ?? join("prisma", "schema"));
86
+ const today = deps.today ?? new Date().toISOString().slice(0, 10);
87
+ const logger = deps.logger ?? console;
88
+ const header = buildHeader(deps.version, today);
89
+
90
+ const written: string[] = [];
91
+ const skipped: { file: string; reason: string }[] = [];
92
+
93
+ if (!opts.dryRun) {
94
+ mkdirSync(target, { recursive: true });
95
+ } else if (!existsSync(target) || !statSync(target).isDirectory()) {
96
+ logger.info(`(dry-run) would create directory ${target}`);
97
+ }
98
+
99
+ for (const file of listFragmentFiles(deps.sourceDir)) {
100
+ const srcPath = join(deps.sourceDir, file);
101
+ const destPath = join(target, `${SYNC_PREFIX}${file}`);
102
+ const existingVersion = readExistingVersion(destPath);
103
+
104
+ if (existingVersion && compareVersions(existingVersion, deps.version) > 0) {
105
+ skipped.push({
106
+ file: destPath,
107
+ reason: `existing version ${existingVersion} is newer than installed ${deps.version}`,
108
+ });
109
+ logger.warn(
110
+ `skip ${destPath} — existing v${existingVersion} is newer than installed v${deps.version}`,
111
+ );
112
+ continue;
113
+ }
114
+
115
+ const body = readFileSync(srcPath, "utf8");
116
+ const contents = header + body;
117
+
118
+ if (opts.dryRun) {
119
+ logger.info(`(dry-run) would write ${destPath}`);
120
+ } else {
121
+ writeFileSync(destPath, contents, "utf8");
122
+ logger.info(`wrote ${destPath}`);
123
+ }
124
+ written.push(destPath);
125
+ }
126
+
127
+ return { written, skipped };
128
+ }
129
+
130
+ export interface DoctorOptions {
131
+ target?: string;
132
+ cwd?: string;
133
+ }
134
+
135
+ export interface DoctorResult {
136
+ ok: boolean;
137
+ stale: { file: string; installed: string; found: string }[];
138
+ missing: string[];
139
+ }
140
+
141
+ export function doctorFragments(opts: DoctorOptions, deps: SyncDeps): DoctorResult {
142
+ const cwd = opts.cwd ?? process.cwd();
143
+ const target = resolve(cwd, opts.target ?? join("prisma", "schema"));
144
+ const stale: { file: string; installed: string; found: string }[] = [];
145
+ const missing: string[] = [];
146
+ const logger = deps.logger ?? console;
147
+
148
+ for (const file of listFragmentFiles(deps.sourceDir)) {
149
+ const destPath = join(target, `${SYNC_PREFIX}${file}`);
150
+ const existingVersion = readExistingVersion(destPath);
151
+ if (existingVersion === null) {
152
+ missing.push(destPath);
153
+ logger.warn(`missing: ${destPath}`);
154
+ continue;
155
+ }
156
+ if (compareVersions(existingVersion, deps.version) < 0) {
157
+ stale.push({
158
+ file: destPath,
159
+ installed: deps.version,
160
+ found: existingVersion,
161
+ });
162
+ logger.warn(
163
+ `stale: ${destPath} is v${existingVersion}; installed @gigamusic/links is v${deps.version}`,
164
+ );
165
+ }
166
+ }
167
+
168
+ return { ok: stale.length === 0 && missing.length === 0, stale, missing };
169
+ }
package/src/client.ts ADDED
@@ -0,0 +1,10 @@
1
+ "use client";
2
+
3
+ // Client-only entry — components that use React context or browser APIs.
4
+ export { LinksSlotsProvider, useLinksSlot, defaultLinkSlots } from "./slots-context";
5
+ export { LinkPageView } from "./components/LinkPageView";
6
+ export { DefaultLinkButton } from "./components/LinkButton";
7
+ export { DefaultLinkPageHeader } from "./components/LinkPageHeader";
8
+ export { LinkPageForm } from "./components/admin/LinkPageForm";
9
+ export { NewLinkPageForm } from "./components/admin/NewLinkPageForm";
10
+ export { DeleteLinkPageButton } from "./components/admin/DeleteLinkPageButton";
@@ -0,0 +1,20 @@
1
+ import { LinkPlatformIcon } from "../platforms/icon";
2
+ import type { LinkButtonProps } from "../slots";
3
+
4
+ /**
5
+ * Default link-button: a pill anchor with the platform icon (if known) and the
6
+ * link title. External URLs open in a new tab; same-origin URLs stay in-tab.
7
+ * Consumers can override via `<GigamusicProvider components={{ LinkButton: ... }}>`.
8
+ */
9
+ export function DefaultLinkButton({ href, title, platform, internal }: LinkButtonProps) {
10
+ return (
11
+ <a
12
+ href={href}
13
+ {...(!internal && { target: "_blank", rel: "noopener noreferrer" })}
14
+ className="flex items-center justify-center gap-3 w-full py-3.5 px-4 rounded-full border border-[var(--gm-color-primary)]/30 bg-[var(--gm-color-primary)]/10 backdrop-blur-sm text-[color:var(--gm-color-foreground,white)] font-medium text-sm uppercase tracking-wide hover:bg-[var(--gm-color-primary)]/20 hover:border-[var(--gm-color-primary)]/60 transition-all duration-200"
15
+ >
16
+ {platform && <LinkPlatformIcon platform={platform} size={18} />}
17
+ <span>{title}</span>
18
+ </a>
19
+ );
20
+ }
@@ -0,0 +1,35 @@
1
+ import type { LinkPageHeaderProps } from "../slots";
2
+
3
+ /**
4
+ * Default header for `LinkPageView`: optional cover image, page title, and
5
+ * description. Consumers can override via `<GigamusicProvider components={{
6
+ * LinkPageHeader: ... }}>` to swap in a logo, custom typography, etc.
7
+ */
8
+ export function DefaultLinkPageHeader({ title, description, cover }: LinkPageHeaderProps) {
9
+ return (
10
+ <>
11
+ {cover && (
12
+ <div className="w-36 h-36 sm:w-44 sm:h-44 rounded-2xl overflow-hidden ring-2 ring-white/20">
13
+ {/* Plain <img> to keep the component framework-agnostic; consumers
14
+ wanting next/image can override this slot. */}
15
+ {/* eslint-disable-next-line @next/next/no-img-element */}
16
+ <img
17
+ src={cover.src}
18
+ alt={cover.alt}
19
+ width={400}
20
+ height={400}
21
+ className="w-full h-full object-cover"
22
+ loading="eager"
23
+ decoding="async"
24
+ />
25
+ </div>
26
+ )}
27
+ <div className="text-center">
28
+ <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
29
+ {description && (
30
+ <p className="text-white/60 text-sm mt-1 whitespace-pre-line">{description}</p>
31
+ )}
32
+ </div>
33
+ </>
34
+ );
35
+ }
@@ -0,0 +1,64 @@
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
+ }