@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,152 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Heuristic check for "internal" URLs that should NOT open in a new tab.
|
|
3
|
-
* Relative paths, hashes, and `mailto:` targets stay in-tab. Anything with
|
|
4
|
-
* an explicit `http(s)` protocol counts as external.
|
|
5
|
-
*/
|
|
6
|
-
export function isInternalUrl(url: string): boolean {
|
|
7
|
-
if (!url) return true;
|
|
8
|
-
if (url.startsWith("#")) return true;
|
|
9
|
-
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
|
10
|
-
if (url.startsWith("mailto:") || url.startsWith("tel:")) return true;
|
|
11
|
-
try {
|
|
12
|
-
const u = new URL(url);
|
|
13
|
-
return u.protocol !== "http:" && u.protocol !== "https:";
|
|
14
|
-
} catch {
|
|
15
|
-
// Failed to parse — treat as relative (internal).
|
|
16
|
-
return true;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
LinkPageForm,
|
|
3
|
-
type LinkPageFormPage,
|
|
4
|
-
type LinkPageFormReleaseOption,
|
|
5
|
-
} from "../components/admin/LinkPageForm";
|
|
6
|
-
import { DeleteLinkPageButton } from "../components/admin/DeleteLinkPageButton";
|
|
7
|
-
|
|
8
|
-
export interface AdminEditLinkPagePageProps {
|
|
9
|
-
page: LinkPageFormPage;
|
|
10
|
-
releases: LinkPageFormReleaseOption[];
|
|
11
|
-
/** Absolute URL prefix used by the "Copy URL" button (e.g. `https://artist.example`). */
|
|
12
|
-
baseUrl: string;
|
|
13
|
-
/** Optional file-upload hook for the cover image; see `LinkPageForm.onUploadCover`. */
|
|
14
|
-
onUploadCover?: (file: File, pageId: number) => Promise<string | null>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Admin "edit link page" page body. The consumer loads the page row + the
|
|
19
|
-
* full release list and passes them in. `baseUrl` is the consumer's site
|
|
20
|
-
* origin (used for the "Copy URL" button); it stays out of this package so
|
|
21
|
-
* we don't need `process.env`.
|
|
22
|
-
*/
|
|
23
|
-
export function AdminEditLinkPagePage({
|
|
24
|
-
page,
|
|
25
|
-
releases,
|
|
26
|
-
baseUrl,
|
|
27
|
-
onUploadCover,
|
|
28
|
-
}: AdminEditLinkPagePageProps) {
|
|
29
|
-
return (
|
|
30
|
-
<div className="max-w-2xl">
|
|
31
|
-
<a
|
|
32
|
-
href={`/links/${page.slug}`}
|
|
33
|
-
target="_blank"
|
|
34
|
-
rel="noopener noreferrer"
|
|
35
|
-
className="text-sm text-[var(--gm-color-primary)] hover:underline"
|
|
36
|
-
>
|
|
37
|
-
View public page →
|
|
38
|
-
</a>
|
|
39
|
-
<div className="flex items-baseline gap-6 mb-6">
|
|
40
|
-
<h1>{page.title}</h1>
|
|
41
|
-
<div className="ml-auto">
|
|
42
|
-
<DeleteLinkPageButton
|
|
43
|
-
pageId={page.id}
|
|
44
|
-
pageTitle={page.title}
|
|
45
|
-
redirectOnDelete
|
|
46
|
-
/>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
<LinkPageForm
|
|
50
|
-
page={page}
|
|
51
|
-
releases={releases}
|
|
52
|
-
baseUrl={baseUrl}
|
|
53
|
-
onUploadCover={onUploadCover}
|
|
54
|
-
/>
|
|
55
|
-
</div>
|
|
56
|
-
);
|
|
57
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import Link from "next/link";
|
|
2
|
-
import type { LinkPage, LinkPageQueries } from "../types";
|
|
3
|
-
import { DeleteLinkPageButton } from "../components/admin/DeleteLinkPageButton";
|
|
4
|
-
|
|
5
|
-
export interface AdminLinkPagesIndexPageProps {
|
|
6
|
-
pages: LinkPage[];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Admin index — list every link page with edit + delete actions and the count
|
|
11
|
-
* of items per page. Consumers fetch with `queries.listAllLinkPages()` and
|
|
12
|
-
* pass `pages` in.
|
|
13
|
-
*/
|
|
14
|
-
export function AdminLinkPagesIndexPage({ pages }: AdminLinkPagesIndexPageProps) {
|
|
15
|
-
return (
|
|
16
|
-
<div>
|
|
17
|
-
<div className="flex items-center justify-between mb-1">
|
|
18
|
-
<h1>Link Pages</h1>
|
|
19
|
-
<Link
|
|
20
|
-
href="/admin/link-pages/new"
|
|
21
|
-
className="text-sm rounded-md px-3 py-1.5 bg-[var(--gm-color-primary)]/20 text-[var(--gm-color-primary)] hover:bg-[var(--gm-color-primary)]/30"
|
|
22
|
-
>
|
|
23
|
-
New Page
|
|
24
|
-
</Link>
|
|
25
|
-
</div>
|
|
26
|
-
<p className="text-sm text-muted-foreground mb-6 max-w-2xl">
|
|
27
|
-
Standalone shareable pages — one URL per release (or campaign) with
|
|
28
|
-
streaming-service buttons (Spotify, Apple Music, YouTube, etc.). Icons
|
|
29
|
-
auto-detect from each URL.
|
|
30
|
-
</p>
|
|
31
|
-
|
|
32
|
-
<table className="w-full text-sm">
|
|
33
|
-
<thead className="text-left text-xs uppercase text-muted-foreground">
|
|
34
|
-
<tr>
|
|
35
|
-
<th className="py-2">Title</th>
|
|
36
|
-
<th className="py-2">Public URL</th>
|
|
37
|
-
<th className="py-2 text-right">Status</th>
|
|
38
|
-
<th className="py-2" />
|
|
39
|
-
</tr>
|
|
40
|
-
</thead>
|
|
41
|
-
<tbody>
|
|
42
|
-
{pages.map((page) => (
|
|
43
|
-
<tr key={page.id} className="border-t border-white/10">
|
|
44
|
-
<td className="py-2 font-medium">
|
|
45
|
-
<Link
|
|
46
|
-
href={`/admin/link-pages/${page.id}/edit`}
|
|
47
|
-
className="hover:underline"
|
|
48
|
-
>
|
|
49
|
-
{page.title}
|
|
50
|
-
</Link>
|
|
51
|
-
</td>
|
|
52
|
-
<td className="py-2">
|
|
53
|
-
<a
|
|
54
|
-
href={`/links/${page.slug}`}
|
|
55
|
-
target="_blank"
|
|
56
|
-
rel="noopener noreferrer"
|
|
57
|
-
className="text-sm text-[var(--gm-color-primary)] hover:underline"
|
|
58
|
-
>
|
|
59
|
-
/links/{page.slug}
|
|
60
|
-
</a>
|
|
61
|
-
</td>
|
|
62
|
-
<td className="py-2 text-right">
|
|
63
|
-
<span
|
|
64
|
-
className={
|
|
65
|
-
page.isPublished
|
|
66
|
-
? "text-[var(--gm-color-primary)]"
|
|
67
|
-
: "text-muted-foreground"
|
|
68
|
-
}
|
|
69
|
-
>
|
|
70
|
-
{page.isPublished ? "Published" : "Draft"}
|
|
71
|
-
</span>
|
|
72
|
-
</td>
|
|
73
|
-
<td className="py-2 text-right">
|
|
74
|
-
<DeleteLinkPageButton pageId={page.id} pageTitle={page.title} />
|
|
75
|
-
</td>
|
|
76
|
-
</tr>
|
|
77
|
-
))}
|
|
78
|
-
{pages.length === 0 && (
|
|
79
|
-
<tr>
|
|
80
|
-
<td colSpan={4} className="py-8 text-center text-muted-foreground">
|
|
81
|
-
No link pages yet.
|
|
82
|
-
</td>
|
|
83
|
-
</tr>
|
|
84
|
-
)}
|
|
85
|
-
</tbody>
|
|
86
|
-
</table>
|
|
87
|
-
</div>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Returns a page component bound to a queries factory. Use when you want the
|
|
93
|
-
* `() => JSX.Element` signature from the plan:
|
|
94
|
-
*
|
|
95
|
-
* ```ts
|
|
96
|
-
* const AdminLinkPagesIndexPage = createAdminLinkPagesIndexPage({ queries });
|
|
97
|
-
* export default AdminLinkPagesIndexPage;
|
|
98
|
-
* ```
|
|
99
|
-
*/
|
|
100
|
-
export function createAdminLinkPagesIndexPage(deps: {
|
|
101
|
-
queries: Pick<LinkPageQueries, "listAllLinkPages">;
|
|
102
|
-
}) {
|
|
103
|
-
return async function AdminLinkPagesIndexPageBound() {
|
|
104
|
-
const pages = await deps.queries.listAllLinkPages();
|
|
105
|
-
return <AdminLinkPagesIndexPage pages={pages} />;
|
|
106
|
-
};
|
|
107
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
NewLinkPageForm,
|
|
3
|
-
type NewLinkPageReleaseOption,
|
|
4
|
-
} from "../components/admin/NewLinkPageForm";
|
|
5
|
-
|
|
6
|
-
export interface AdminNewLinkPagePageProps {
|
|
7
|
-
releases: NewLinkPageReleaseOption[];
|
|
8
|
-
/** Pre-selected release (when arriving via "Create link page for this release" link from the release edit page). */
|
|
9
|
-
initialRelease?: NewLinkPageReleaseOption;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Admin "new link page" page body. Consumer fetches the release options
|
|
14
|
-
* (typically `queries.listAllReleases().map(toOption)`) and passes them in.
|
|
15
|
-
*/
|
|
16
|
-
export function AdminNewLinkPagePage({
|
|
17
|
-
releases,
|
|
18
|
-
initialRelease,
|
|
19
|
-
}: AdminNewLinkPagePageProps) {
|
|
20
|
-
return (
|
|
21
|
-
<div className="max-w-2xl">
|
|
22
|
-
<h1>New Link Page</h1>
|
|
23
|
-
<NewLinkPageForm releases={releases} initialRelease={initialRelease} />
|
|
24
|
-
</div>
|
|
25
|
-
);
|
|
26
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import type { Metadata } from "next";
|
|
2
|
-
import { notFound } from "next/navigation";
|
|
3
|
-
import { LinkPageView } from "../components/LinkPageView";
|
|
4
|
-
import type { LinkPageWithItems, LinkPageQueries } from "../types";
|
|
5
|
-
import type { GigamusicConfig } from "@gigamusic/config";
|
|
6
|
-
|
|
7
|
-
export interface PublicLinkPageProps {
|
|
8
|
-
slug: string;
|
|
9
|
-
/**
|
|
10
|
-
* The page row. Consumers fetch it via their merged `Queries` and pass it
|
|
11
|
-
* in — keeping this package free of any DB coupling at the page boundary.
|
|
12
|
-
* When `null`, the component triggers `notFound()`.
|
|
13
|
-
*/
|
|
14
|
-
page: LinkPageWithItems | null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Page body for `app/links/[slug]/page.tsx`. Calls `notFound()` when `page`
|
|
19
|
-
* is null. The consumer is expected to look up the page using
|
|
20
|
-
* `queries.getPublicLinkPageBySlug(slug)` before rendering.
|
|
21
|
-
*
|
|
22
|
-
* The companion factory `createPublicLinkPage({ queries })` wraps this in a
|
|
23
|
-
* pre-fetching shell so consumers can mount a single component without the
|
|
24
|
-
* boilerplate.
|
|
25
|
-
*/
|
|
26
|
-
export function PublicLinkPage({ page }: PublicLinkPageProps) {
|
|
27
|
-
if (!page) notFound();
|
|
28
|
-
return <LinkPageView page={page} />;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface CreatePublicLinkPageDeps {
|
|
32
|
-
queries: Pick<LinkPageQueries, "getPublicLinkPageBySlug">;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Returns a JSX-returning page that fetches a link page by slug and renders
|
|
37
|
-
* `PublicLinkPage`. Use this when you want the bound `{ slug }` signature
|
|
38
|
-
* called out in `plans/11-links.md`:
|
|
39
|
-
*
|
|
40
|
-
* ```ts
|
|
41
|
-
* const PublicLinkPage = createPublicLinkPage({ queries });
|
|
42
|
-
* export default async function Page({ params }) {
|
|
43
|
-
* const { slug } = await params;
|
|
44
|
-
* return <PublicLinkPage slug={slug} />;
|
|
45
|
-
* }
|
|
46
|
-
* ```
|
|
47
|
-
*/
|
|
48
|
-
export function createPublicLinkPage({ queries }: CreatePublicLinkPageDeps) {
|
|
49
|
-
return async function PublicLinkPageBound({ slug }: { slug: string }) {
|
|
50
|
-
const page = await queries.getPublicLinkPageBySlug(slug);
|
|
51
|
-
return <PublicLinkPage slug={slug} page={page} />;
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface PublicLinkPageMetadataDeps {
|
|
56
|
-
queries: Pick<LinkPageQueries, "getPublicLinkPageBySlug">;
|
|
57
|
-
/** Optional site-level config for OG fallback (site name, default OG image, description). */
|
|
58
|
-
config?: GigamusicConfig;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface PublicLinkPageMetadataArgs {
|
|
62
|
-
slug: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Build a `generateMetadata` implementation. The OG image follows the chain:
|
|
67
|
-
* explicit `page.coverImageUrl` override → linked release's cover (only when
|
|
68
|
-
* the release is published, to avoid leaking a draft's art) → site-level
|
|
69
|
-
* `config.branding.ogImageUrl` (when provided) → none.
|
|
70
|
-
*/
|
|
71
|
-
export function createPublicLinkPageMetadata({
|
|
72
|
-
queries,
|
|
73
|
-
config,
|
|
74
|
-
}: PublicLinkPageMetadataDeps): (
|
|
75
|
-
args: PublicLinkPageMetadataArgs,
|
|
76
|
-
) => Promise<Metadata> {
|
|
77
|
-
return async ({ slug }: PublicLinkPageMetadataArgs): Promise<Metadata> => {
|
|
78
|
-
const page = await queries.getPublicLinkPageBySlug(slug);
|
|
79
|
-
if (!page) return { title: "Not Found" };
|
|
80
|
-
|
|
81
|
-
const releaseCover = page.release?.isPublished
|
|
82
|
-
? page.release.coverImageUrl
|
|
83
|
-
: null;
|
|
84
|
-
const cover = page.coverImageUrl ?? releaseCover ?? config?.branding.ogImageUrl ?? null;
|
|
85
|
-
|
|
86
|
-
return {
|
|
87
|
-
title: page.title,
|
|
88
|
-
description: page.description ?? undefined,
|
|
89
|
-
openGraph: cover
|
|
90
|
-
? { images: [{ url: cover, alt: page.title }] }
|
|
91
|
-
: undefined,
|
|
92
|
-
};
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Single-arg metadata helper matching the locked contract literally. Requires
|
|
98
|
-
* the queries factory to have been registered via
|
|
99
|
-
* `registerLinkPageMetadata({ queries, config })`.
|
|
100
|
-
*/
|
|
101
|
-
let registeredMetadataDeps: PublicLinkPageMetadataDeps | null = null;
|
|
102
|
-
|
|
103
|
-
export function registerLinkPageMetadata(deps: PublicLinkPageMetadataDeps) {
|
|
104
|
-
registeredMetadataDeps = deps;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export const publicLinkPageMetadata = async (
|
|
108
|
-
args: PublicLinkPageMetadataArgs,
|
|
109
|
-
): Promise<Metadata> => {
|
|
110
|
-
if (!registeredMetadataDeps) {
|
|
111
|
-
throw new Error(
|
|
112
|
-
"publicLinkPageMetadata: call registerLinkPageMetadata({ queries, config }) once at app startup before invoking, " +
|
|
113
|
-
"or use createPublicLinkPageMetadata({ queries }) and bind the result yourself.",
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
return createPublicLinkPageMetadata(registeredMetadataDeps)(args);
|
|
117
|
-
};
|
package/src/slots-context.tsx
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
4
|
-
import type { LinksSlots } from "./slots";
|
|
5
|
-
import { DefaultLinkButton } from "./components/LinkButton";
|
|
6
|
-
import { DefaultLinkPageHeader } from "./components/LinkPageHeader";
|
|
7
|
-
|
|
8
|
-
const DEFAULT_LINK_SLOTS: LinksSlots = {
|
|
9
|
-
LinkButton: DefaultLinkButton,
|
|
10
|
-
LinkPageHeader: DefaultLinkPageHeader,
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const LinksSlotsContext = createContext<LinksSlots>(DEFAULT_LINK_SLOTS);
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Optional provider that registers overrides for the `LinksSlots` map.
|
|
17
|
-
* Wrap a subtree (or the whole app) when you want every `LinkPageView` to
|
|
18
|
-
* use your custom `LinkButton` / `LinkPageHeader` without passing the
|
|
19
|
-
* `slots` prop at each call site.
|
|
20
|
-
*/
|
|
21
|
-
export function LinksSlotsProvider({
|
|
22
|
-
components,
|
|
23
|
-
children,
|
|
24
|
-
}: {
|
|
25
|
-
components?: Partial<LinksSlots>;
|
|
26
|
-
children: ReactNode;
|
|
27
|
-
}) {
|
|
28
|
-
const parent = useContext(LinksSlotsContext);
|
|
29
|
-
const merged = useMemo<LinksSlots>(
|
|
30
|
-
() => (components ? { ...parent, ...components } : parent),
|
|
31
|
-
[parent, components],
|
|
32
|
-
);
|
|
33
|
-
return (
|
|
34
|
-
<LinksSlotsContext.Provider value={merged}>
|
|
35
|
-
{children}
|
|
36
|
-
</LinksSlotsContext.Provider>
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Resolve a `LinksSlots` entry from the nearest `LinksSlotsProvider`, falling back to the package default. */
|
|
41
|
-
export function useLinksSlot<K extends keyof LinksSlots>(key: K): LinksSlots[K] {
|
|
42
|
-
const slots = useContext(LinksSlotsContext);
|
|
43
|
-
return slots[key];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export { DEFAULT_LINK_SLOTS as defaultLinkSlots };
|
package/src/slots.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { ComponentType, ReactNode } from "react";
|
|
2
|
-
import type { KnownLinkPlatform } from "./platforms/detect";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Props passed to the `LinkButton` slot. Consumers can swap this to use their
|
|
6
|
-
* own button styling without forking `LinkPageView`.
|
|
7
|
-
*/
|
|
8
|
-
export interface LinkButtonProps {
|
|
9
|
-
href: string;
|
|
10
|
-
title: string;
|
|
11
|
-
/** Detected platform, if any — used to render an inline icon. */
|
|
12
|
-
platform: KnownLinkPlatform | null;
|
|
13
|
-
/** True when the URL is same-origin; the default implementation skips `target="_blank"`. */
|
|
14
|
-
internal: boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Props passed to the `LinkPageHeader` slot — the title block above the social row. */
|
|
18
|
-
export interface LinkPageHeaderProps {
|
|
19
|
-
title: string;
|
|
20
|
-
description: string | null;
|
|
21
|
-
/** Cover image url to render (already resolved per the override → release chain). */
|
|
22
|
-
cover: { src: string; alt: string } | null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Slots specific to `@gigamusic/links`. They live alongside the slots from
|
|
27
|
-
* `@gigamusic/ui`; callers register them on the same `GigamusicProvider`.
|
|
28
|
-
* Adding new entries is a minor-version bump; renaming or changing props of an
|
|
29
|
-
* existing entry is a breaking change.
|
|
30
|
-
*/
|
|
31
|
-
export interface LinksSlots {
|
|
32
|
-
LinkButton: ComponentType<LinkButtonProps>;
|
|
33
|
-
LinkPageHeader: ComponentType<LinkPageHeaderProps>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export type LinksSlotChildren = { children?: ReactNode };
|