@gigamusic/links 0.2.0 → 1.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/src/cli/sync.ts DELETED
@@ -1,169 +0,0 @@
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 DELETED
@@ -1,10 +0,0 @@
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";
@@ -1,20 +0,0 @@
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
- }
@@ -1,35 +0,0 @@
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
- }
@@ -1,64 +0,0 @@
1
- "use client";
2
-
3
- import { detectLinkPlatform } from "../platforms/detect";
4
- import { isInternalUrl } from "./url-helpers";
5
- import { useLinksSlot } from "../slots-context";
6
- import type { LinkPageWithItems } from "../types";
7
- import type { LinksSlots } from "../slots";
8
-
9
- interface LinkPageViewProps {
10
- page: LinkPageWithItems;
11
- /**
12
- * Per-call slot overrides. Most consumers register slots once on
13
- * `<LinksSlotsProvider>` and omit this prop. Keys not present here fall
14
- * back to the provider, then to the package defaults.
15
- */
16
- slots?: Partial<LinksSlots>;
17
- }
18
-
19
- /**
20
- * Public-facing render of a `LinkPage`. Renders the resolved cover, header,
21
- * and a vertical list of link buttons. Slots:
22
- * - `LinkButton` — the rendered pill for each item.
23
- * - `LinkPageHeader` — the title block above the buttons.
24
- *
25
- * Cover image resolution: explicit `page.coverImageUrl` override → linked
26
- * release's `coverImageUrl` (only when the release is published, to avoid
27
- * leaking a draft's art) → `null`.
28
- */
29
- export function LinkPageView({ page, slots }: LinkPageViewProps) {
30
- const providerHeader = useLinksSlot("LinkPageHeader");
31
- const providerButton = useLinksSlot("LinkButton");
32
- const Header = slots?.LinkPageHeader ?? providerHeader;
33
- const LinkButton = slots?.LinkButton ?? providerButton;
34
-
35
- const releaseCover = page.release?.isPublished
36
- ? page.release.coverImageUrl
37
- : null;
38
- const coverSrc = page.coverImageUrl ?? releaseCover ?? null;
39
- const cover = coverSrc ? { src: coverSrc, alt: page.title } : null;
40
-
41
- return (
42
- <div className="min-h-dvh flex items-start justify-center px-4 py-10">
43
- <div className="w-full max-w-md flex flex-col items-center gap-5">
44
- <Header title={page.title} description={page.description} cover={cover} />
45
-
46
- <div className="w-full flex flex-col gap-3 mt-2">
47
- {page.items.map((item) => {
48
- const platform = detectLinkPlatform(item.url);
49
- const internal = isInternalUrl(item.url);
50
- return (
51
- <LinkButton
52
- key={item.id}
53
- href={item.url}
54
- title={item.title}
55
- platform={platform}
56
- internal={internal}
57
- />
58
- );
59
- })}
60
- </div>
61
- </div>
62
- </div>
63
- );
64
- }
@@ -1,52 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { useRouter } from "next/navigation";
5
-
6
- interface Props {
7
- pageId: number;
8
- pageTitle: string;
9
- redirectOnDelete?: boolean;
10
- }
11
-
12
- /**
13
- * Confirm-then-delete control for a single link page. POSTs DELETE to the
14
- * admin route the consumer mounted from `createAdminLinkPageByIdHandlers`.
15
- * Set `redirectOnDelete` when used from the edit page so the user lands back
16
- * on the index after a successful delete.
17
- */
18
- export function DeleteLinkPageButton({
19
- pageId,
20
- pageTitle,
21
- redirectOnDelete,
22
- }: Props) {
23
- const router = useRouter();
24
- const [deleting, setDeleting] = useState(false);
25
-
26
- async function handleDelete() {
27
- if (!confirm(`Delete "${pageTitle}"? Its public URL will return 404.`)) return;
28
- setDeleting(true);
29
- const res = await fetch(`/api/admin/link-pages/${pageId}`, { method: "DELETE" });
30
- if (!res.ok) {
31
- setDeleting(false);
32
- alert("Failed to delete link page");
33
- return;
34
- }
35
- if (redirectOnDelete) {
36
- router.push("/admin/link-pages");
37
- } else {
38
- router.refresh();
39
- }
40
- }
41
-
42
- return (
43
- <button
44
- type="button"
45
- onClick={handleDelete}
46
- disabled={deleting}
47
- className="text-sm rounded-md px-2.5 py-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
48
- >
49
- {deleting ? "Deleting..." : "Delete"}
50
- </button>
51
- );
52
- }