@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,18 @@
|
|
|
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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Server-safe public entry. Everything that requires the React client runtime
|
|
2
|
+
// (context providers, the form components, etc.) lives in `./client`.
|
|
3
|
+
|
|
4
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
5
|
+
export type {
|
|
6
|
+
LinkPage,
|
|
7
|
+
LinkPageItem,
|
|
8
|
+
LinkPageReleaseRef,
|
|
9
|
+
LinkPageWithItems,
|
|
10
|
+
LinkPageQueries,
|
|
11
|
+
LinkPageInput,
|
|
12
|
+
LinkPageItemInput,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
// ── Queries factory ─────────────────────────────────────────────────────
|
|
16
|
+
export { createLinkPageQueries } from "./queries/link-pages";
|
|
17
|
+
|
|
18
|
+
// ── Platform detection ──────────────────────────────────────────────────
|
|
19
|
+
export { detectLinkPlatform, PLATFORM_LABELS } from "./platforms/detect";
|
|
20
|
+
export type { KnownLinkPlatform } from "./platforms/detect";
|
|
21
|
+
export { LinkPlatformIcon } from "./platforms/icon";
|
|
22
|
+
|
|
23
|
+
// ── Slots ────────────────────────────────────────────────────────────────
|
|
24
|
+
export type {
|
|
25
|
+
LinksSlots,
|
|
26
|
+
LinkButtonProps,
|
|
27
|
+
LinkPageHeaderProps,
|
|
28
|
+
} from "./slots";
|
|
29
|
+
|
|
30
|
+
// ── Public render surface ───────────────────────────────────────────────
|
|
31
|
+
export { LinkPageView } from "./components/LinkPageView";
|
|
32
|
+
export { DefaultLinkButton } from "./components/LinkButton";
|
|
33
|
+
export { DefaultLinkPageHeader } from "./components/LinkPageHeader";
|
|
34
|
+
|
|
35
|
+
// ── Public page (per-slug landing) ──────────────────────────────────────
|
|
36
|
+
export {
|
|
37
|
+
PublicLinkPage,
|
|
38
|
+
createPublicLinkPage,
|
|
39
|
+
createPublicLinkPageMetadata,
|
|
40
|
+
publicLinkPageMetadata,
|
|
41
|
+
registerLinkPageMetadata,
|
|
42
|
+
} from "./pages/PublicLinkPage";
|
|
43
|
+
export type {
|
|
44
|
+
PublicLinkPageProps,
|
|
45
|
+
CreatePublicLinkPageDeps,
|
|
46
|
+
PublicLinkPageMetadataDeps,
|
|
47
|
+
PublicLinkPageMetadataArgs,
|
|
48
|
+
} from "./pages/PublicLinkPage";
|
|
49
|
+
|
|
50
|
+
// ── Admin page bodies ───────────────────────────────────────────────────
|
|
51
|
+
export {
|
|
52
|
+
AdminLinkPagesIndexPage,
|
|
53
|
+
createAdminLinkPagesIndexPage,
|
|
54
|
+
} from "./pages/AdminLinkPagesIndexPage";
|
|
55
|
+
export type { AdminLinkPagesIndexPageProps } from "./pages/AdminLinkPagesIndexPage";
|
|
56
|
+
export { AdminNewLinkPagePage } from "./pages/AdminNewLinkPagePage";
|
|
57
|
+
export type { AdminNewLinkPagePageProps } from "./pages/AdminNewLinkPagePage";
|
|
58
|
+
export { AdminEditLinkPagePage } from "./pages/AdminEditLinkPagePage";
|
|
59
|
+
export type { AdminEditLinkPagePageProps } from "./pages/AdminEditLinkPagePage";
|
|
60
|
+
|
|
61
|
+
// ── Admin form types (so consumers can build their own page wrappers) ───
|
|
62
|
+
export type {
|
|
63
|
+
LinkPageFormPage,
|
|
64
|
+
LinkPageFormItem,
|
|
65
|
+
LinkPageFormReleaseOption,
|
|
66
|
+
} from "./components/admin/LinkPageForm";
|
|
67
|
+
export type { NewLinkPageReleaseOption } from "./components/admin/NewLinkPageForm";
|
|
68
|
+
|
|
69
|
+
// Admin API handler factories live on the `./server` subpath — they import
|
|
70
|
+
// `next/cache`, which Webpack/Turbopack refuse to bundle into a client tree.
|
|
71
|
+
// Use `import { createAdminLinkPagesHandlers } from "@gigamusic/links/server"`.
|
|
72
|
+
|
|
73
|
+
// ── Slug validation helpers ─────────────────────────────────────────────
|
|
74
|
+
export {
|
|
75
|
+
SLUG_PATTERN,
|
|
76
|
+
RESERVED_SLUGS,
|
|
77
|
+
INVALID_SLUG_MESSAGE,
|
|
78
|
+
RESERVED_SLUG_MESSAGE,
|
|
79
|
+
isUniqueConstraintError,
|
|
80
|
+
} from "./api/validation";
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { KnownPlatform } from "@gigamusic/config";
|
|
2
|
+
|
|
3
|
+
export type KnownLinkPlatform = KnownPlatform;
|
|
4
|
+
|
|
5
|
+
interface HostPattern {
|
|
6
|
+
platform: KnownLinkPlatform;
|
|
7
|
+
match: RegExp;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hostname patterns for each known streaming/social platform. Ordered by
|
|
12
|
+
* specificity; the first match wins. `apple-music` is matched only for the
|
|
13
|
+
* `music.apple.com` subdomain so we don't grab plain `apple.com` URLs.
|
|
14
|
+
*/
|
|
15
|
+
const HOSTNAME_PATTERNS: HostPattern[] = [
|
|
16
|
+
{ platform: "spotify", match: /(^|\.)spotify\.com$|(^|\.)spotify\.link$/i },
|
|
17
|
+
{ platform: "apple-music", match: /(^|\.)music\.apple\.com$|(^|\.)apple\.co$/i },
|
|
18
|
+
{ platform: "youtube", match: /(^|\.)youtube\.com$|(^|\.)youtu\.be$/i },
|
|
19
|
+
{ platform: "soundcloud", match: /(^|\.)soundcloud\.com$|(^|\.)snd\.sc$/i },
|
|
20
|
+
{ platform: "instagram", match: /(^|\.)instagram\.com$|(^|\.)instagr\.am$/i },
|
|
21
|
+
{ platform: "facebook", match: /(^|\.)facebook\.com$|(^|\.)fb\.com$/i },
|
|
22
|
+
{ platform: "bandcamp", match: /(^|\.)bandcamp\.com$/i },
|
|
23
|
+
{ platform: "tiktok", match: /(^|\.)tiktok\.com$/i },
|
|
24
|
+
{ platform: "x", match: /(^|\.)x\.com$|(^|\.)twitter\.com$/i },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** Match a URL to a known platform by hostname. Returns null when nothing matches or the URL is malformed. */
|
|
28
|
+
export function detectLinkPlatform(url: string): KnownLinkPlatform | null {
|
|
29
|
+
let host: string;
|
|
30
|
+
try {
|
|
31
|
+
host = new URL(url).hostname;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
for (const { platform, match } of HOSTNAME_PATTERNS) {
|
|
36
|
+
if (match.test(host)) return platform;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const PLATFORM_LABELS: Record<KnownLinkPlatform, string> = {
|
|
42
|
+
spotify: "Spotify",
|
|
43
|
+
"apple-music": "Apple Music",
|
|
44
|
+
youtube: "YouTube",
|
|
45
|
+
soundcloud: "SoundCloud",
|
|
46
|
+
instagram: "Instagram",
|
|
47
|
+
facebook: "Facebook",
|
|
48
|
+
bandcamp: "Bandcamp",
|
|
49
|
+
tiktok: "TikTok",
|
|
50
|
+
x: "X",
|
|
51
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { KnownLinkPlatform } from "./detect";
|
|
2
|
+
|
|
3
|
+
interface PlatformIconProps {
|
|
4
|
+
platform: KnownLinkPlatform;
|
|
5
|
+
size?: number;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SVG path data for the platforms we can render with a single `<path>` (or a
|
|
11
|
+
* few). `soundcloud` lives in its own SVG below because the official mark needs
|
|
12
|
+
* a different viewBox.
|
|
13
|
+
*/
|
|
14
|
+
const PLATFORM_PATHS: Record<
|
|
15
|
+
Exclude<KnownLinkPlatform, "soundcloud">,
|
|
16
|
+
React.ReactNode
|
|
17
|
+
> = {
|
|
18
|
+
spotify: (
|
|
19
|
+
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm4.6 14.4a.6.6 0 0 1-.84.2c-2.3-1.4-5.2-1.7-8.6-.9a.6.6 0 1 1-.28-1.18c3.7-.88 6.9-.5 9.5 1.04a.6.6 0 0 1 .22.84zm1.2-2.72a.78.78 0 0 1-1.06.26c-2.6-1.6-6.6-2.06-9.7-1.12a.78.78 0 1 1-.44-1.5c3.5-1.06 7.9-.54 10.94 1.3a.78.78 0 0 1 .26 1.06zm.1-2.84C14.6 8.8 9.5 8.6 6.4 9.56a.94.94 0 1 1-.54-1.8c3.5-1.06 9.4-.86 13.1 1.34a.94.94 0 0 1-.54 1.74z" />
|
|
20
|
+
),
|
|
21
|
+
"apple-music": (
|
|
22
|
+
<>
|
|
23
|
+
<path d="M9 18V5l12-2v13" />
|
|
24
|
+
<circle cx="6" cy="18" r="3" />
|
|
25
|
+
<circle cx="18" cy="16" r="3" />
|
|
26
|
+
</>
|
|
27
|
+
),
|
|
28
|
+
youtube: (
|
|
29
|
+
<>
|
|
30
|
+
<path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17" />
|
|
31
|
+
<path d="m10 15 5-3-5-3z" />
|
|
32
|
+
</>
|
|
33
|
+
),
|
|
34
|
+
instagram: (
|
|
35
|
+
<>
|
|
36
|
+
<rect width="20" height="20" x="2" y="2" rx="5" ry="5" />
|
|
37
|
+
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
|
|
38
|
+
<line x1="17.5" x2="17.51" y1="6.5" y2="6.5" />
|
|
39
|
+
</>
|
|
40
|
+
),
|
|
41
|
+
facebook: (
|
|
42
|
+
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z" />
|
|
43
|
+
),
|
|
44
|
+
bandcamp: <path d="M3 17h11l7-10H10z" />,
|
|
45
|
+
tiktok: (
|
|
46
|
+
<path d="M16 3v3a4 4 0 0 0 4 4v3a7 7 0 0 1-4-1.25V16a5 5 0 1 1-5-5h1v3.5h-1A1.5 1.5 0 1 0 12 16V3z" />
|
|
47
|
+
),
|
|
48
|
+
x: <path d="M4 4l16 16M20 4L4 20" />,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function SoundCloudGlyph({ size }: { size: number }) {
|
|
52
|
+
return (
|
|
53
|
+
<svg
|
|
54
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
55
|
+
width={size}
|
|
56
|
+
height={size}
|
|
57
|
+
viewBox="0 0 10000 4000"
|
|
58
|
+
fill="currentColor"
|
|
59
|
+
stroke="none"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
>
|
|
62
|
+
<g transform="translate(0,7000) scale(1,-1)">
|
|
63
|
+
<path d="M5610 6761 c-14 -5 -67 -14 -119 -21 -146 -19 -272 -56 -406 -120 -72 -35 -113 -61 -142 -93 -79 -85 -74 37 -71 -1703 3 -1742 -4 -1582 81 -1661 25 -23 62 -46 83 -52 54 -15 2926 -15 3043 -1 304 39 585 195 773 429 164 206 238 416 238 681 0 146 -15 240 -61 370 -122 348 -416 618 -775 710 -158 41 -348 46 -508 14 -71 -15 -85 -11 -86 23 0 35 -94 291 -139 382 -104 206 -200 338 -368 503 -165 163 -285 248 -498 353 -188 93 -345 142 -565 177 -82 13 -447 20 -480 9z" />
|
|
64
|
+
<path d="M4367 6211 c-22 -26 -25 -43 -36 -187 -7 -88 -16 -213 -21 -279 -34 -431 -44 -702 -44 -1255 0 -567 8 -773 44 -1060 5 -41 14 -122 21 -180 11 -92 16 -108 40 -132 37 -37 81 -37 118 0 23 23 29 41 40 122 7 52 16 124 21 160 48 348 74 1187 50 1585 -4 66 -11 197 -14 290 -4 94 -9 177 -11 185 -2 8 -6 65 -10 125 -3 61 -8 130 -10 155 -3 25 -12 132 -20 238 -19 236 -29 261 -107 262 -28 0 -42 -7 -61 -29z" />
|
|
65
|
+
<path d="M3242 5964 c-45 -31 -44 -20 -81 -509 -35 -453 -43 -655 -43 -1060 1 -395 11 -601 47 -945 31 -296 35 -315 77 -344 55 -39 124 -2 138 74 9 49 43 347 59 525 23 236 32 702 21 1035 -10 329 -15 407 -51 830 -30 352 -31 359 -63 387 -31 27 -72 30 -104 7z" />
|
|
66
|
+
<path d="M3834 5893 c-44 -8 -66 -46 -75 -130 -13 -120 -28 -295 -46 -543 -21 -286 -25 -1261 -5 -1500 23 -287 50 -533 63 -567 17 -47 85 -69 134 -43 44 24 48 38 84 390 28 272 29 283 41 580 18 440 -6 1077 -60 1560 -5 47 -12 109 -15 138 -7 69 -19 90 -60 108 -19 8 -36 13 -37 13 -2 -1 -13 -4 -24 -6z" />
|
|
67
|
+
<path d="M2662 5695 c-39 -33 -44 -59 -82 -495 -53 -617 -45 -1232 25 -1870 9 -74 18 -147 21 -162 7 -34 58 -78 90 -78 28 0 71 25 83 49 9 16 41 257 60 451 58 583 44 1349 -34 1945 -17 127 -30 160 -71 175 -40 15 -60 12 -92 -15z" />
|
|
68
|
+
<path d="M1517 5179 c-30 -18 -44 -66 -66 -224 -65 -486 -65 -882 -1 -1455 33 -299 37 -310 120 -310 73 0 91 23 105 135 3 28 13 102 21 165 34 265 46 430 51 690 6 295 -3 458 -38 692 -33 225 -44 270 -74 295 -28 25 -86 30 -118 12z" />
|
|
69
|
+
</g>
|
|
70
|
+
</svg>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Inline SVG icon for a platform. Inherits color via `currentColor`. The SVGs
|
|
76
|
+
* are deliberately path-only (no external font/icon dependency) so they render
|
|
77
|
+
* identically in any consumer theme.
|
|
78
|
+
*/
|
|
79
|
+
export function LinkPlatformIcon({ platform, size = 24, className }: PlatformIconProps) {
|
|
80
|
+
if (platform === "soundcloud") {
|
|
81
|
+
return (
|
|
82
|
+
<span
|
|
83
|
+
className={["inline-flex items-center justify-center", className].filter(Boolean).join(" ")}
|
|
84
|
+
>
|
|
85
|
+
<SoundCloudGlyph size={size * 0.7} />
|
|
86
|
+
</span>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const path = PLATFORM_PATHS[platform];
|
|
90
|
+
const dim = platform === "youtube" ? size * 1.2 : size;
|
|
91
|
+
return (
|
|
92
|
+
<svg
|
|
93
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
94
|
+
width={dim}
|
|
95
|
+
height={dim}
|
|
96
|
+
viewBox="0 0 24 24"
|
|
97
|
+
fill="none"
|
|
98
|
+
stroke="currentColor"
|
|
99
|
+
strokeWidth="2"
|
|
100
|
+
strokeLinecap="round"
|
|
101
|
+
strokeLinejoin="round"
|
|
102
|
+
className={className}
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
>
|
|
105
|
+
{path}
|
|
106
|
+
</svg>
|
|
107
|
+
);
|
|
108
|
+
}
|