@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.
@@ -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
- };
@@ -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 };