@gigamusic/links 2.1.1 → 4.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 +16 -25
- package/package.json +5 -13
- package/src/api/handlers.ts +122 -55
- package/src/index.ts +5 -9
- package/src/server.ts +3 -5
- package/src/queries/link-pages.ts +0 -170
- package/src/schema/index.ts +0 -2
- package/src/schema/link-pages.ts +0 -41
- package/src/schema/relations.ts +0 -12
- package/src/types.ts +0 -47
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# @gigamusic/links
|
|
2
2
|
|
|
3
|
-
Linktree-style per-release link pages for the gigamusic platform. Ships
|
|
3
|
+
Linktree-style per-release link pages for the gigamusic platform. Ships admin API handler factories, platform-detection helpers, and a small SVG `LinkPlatformIcon` component.
|
|
4
|
+
|
|
5
|
+
The `link_pages` / `link_page_items` Drizzle schema **and** the link-page queries live in `@gigamusic/db` — they're folded into the single `createQueries(db)` so a consumer builds one query object for every table. This package re-exports the link-page row types for convenience.
|
|
4
6
|
|
|
5
7
|
```ts
|
|
6
8
|
import {
|
|
7
|
-
createLinkPageQueries,
|
|
8
9
|
detectLinkPlatform,
|
|
9
10
|
PLATFORM_LABELS,
|
|
10
11
|
LinkPlatformIcon,
|
|
11
12
|
type KnownLinkPlatform,
|
|
13
|
+
type LinkPageWithItems,
|
|
12
14
|
} from "@gigamusic/links";
|
|
13
15
|
|
|
14
16
|
// Admin handler factories live on the /server subpath
|
|
@@ -19,43 +21,32 @@ import {
|
|
|
19
21
|
} from "@gigamusic/links/server";
|
|
20
22
|
```
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
## Schema subpath
|
|
24
|
+
## Reads and writes
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
All link-page DB access comes from `@gigamusic/db`'s queries:
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
linkPageItemColumns,
|
|
36
|
-
} from "@gigamusic/links/schema";
|
|
29
|
+
import { createQueries } from "@gigamusic/db";
|
|
30
|
+
|
|
31
|
+
const queries = createQueries(db); // one object, every table
|
|
32
|
+
|
|
33
|
+
// Public link-page UI — fetch and render whatever you want:
|
|
34
|
+
const page = await queries.getPublicLinkPageBySlug(slug);
|
|
37
35
|
```
|
|
38
36
|
|
|
39
|
-
|
|
37
|
+
The admin handler factories from `@gigamusic/links/server` take that same `queries` object:
|
|
40
38
|
|
|
41
39
|
```ts
|
|
42
|
-
|
|
43
|
-
import { Pool } from "pg";
|
|
44
|
-
import * as dbSchema from "@gigamusic/db/schema";
|
|
45
|
-
import * as linksSchema from "@gigamusic/links/schema";
|
|
46
|
-
|
|
47
|
-
const schema = { ...dbSchema, ...linksSchema };
|
|
48
|
-
export const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }), { schema });
|
|
40
|
+
const handlers = createAdminLinkPagesHandlers({ queries });
|
|
49
41
|
```
|
|
50
42
|
|
|
51
43
|
## Release back-reference
|
|
52
44
|
|
|
53
|
-
`linkPages.releaseId` is a nullable FK to `releases.id
|
|
45
|
+
`linkPages.releaseId` is a nullable FK to `releases.id`. Both tables and their relations live in `@gigamusic/db/schema`; `linkPagesRelations` already declares the `linkPage.release` direction. If you want a `release.linkPages` back-reference (for `db.query.releases.findFirst({ with: { linkPages: true } })`), compose it onto the base `releasesRelations` in your own schema file:
|
|
54
46
|
|
|
55
47
|
```ts
|
|
56
48
|
import { relations } from "drizzle-orm";
|
|
57
|
-
import { releases } from "@gigamusic/db/schema";
|
|
58
|
-
import { linkPages } from "@gigamusic/links/schema";
|
|
49
|
+
import { releases, linkPages } from "@gigamusic/db/schema";
|
|
59
50
|
|
|
60
51
|
export const releasesRelations = relations(releases, ({ many }) => ({
|
|
61
52
|
// ...keep the existing many() relations from @gigamusic/db/schema if you need them...
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gigamusic/links",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Editable Linktree-style link pages for gigamusic artist sites. Ships
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Editable Linktree-style link pages for gigamusic artist sites. Ships admin API handler factories and platform-detection helpers (the Drizzle schema + queries live in @gigamusic/db).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -17,10 +17,6 @@
|
|
|
17
17
|
"./server": {
|
|
18
18
|
"types": "./src/server.ts",
|
|
19
19
|
"default": "./src/server.ts"
|
|
20
|
-
},
|
|
21
|
-
"./schema": {
|
|
22
|
-
"types": "./src/schema/index.ts",
|
|
23
|
-
"default": "./src/schema/index.ts"
|
|
24
20
|
}
|
|
25
21
|
},
|
|
26
22
|
"files": [
|
|
@@ -31,23 +27,19 @@
|
|
|
31
27
|
"access": "public"
|
|
32
28
|
},
|
|
33
29
|
"dependencies": {
|
|
34
|
-
"
|
|
35
|
-
"@gigamusic/
|
|
30
|
+
"zod": "^3.24.1",
|
|
31
|
+
"@gigamusic/core": "3.0.0",
|
|
32
|
+
"@gigamusic/db": "4.0.0"
|
|
36
33
|
},
|
|
37
34
|
"peerDependencies": {
|
|
38
|
-
"drizzle-orm": ">=0.45",
|
|
39
35
|
"next": ">=15",
|
|
40
36
|
"react": ">=18 <20",
|
|
41
37
|
"react-dom": ">=18 <20"
|
|
42
38
|
},
|
|
43
39
|
"devDependencies": {
|
|
44
40
|
"@types/node": "^22.10.5",
|
|
45
|
-
"@types/pg": "^8.11.10",
|
|
46
41
|
"@types/react": "^19.0.0",
|
|
47
|
-
"drizzle-kit": "^0.31.10",
|
|
48
|
-
"drizzle-orm": "^0.45.2",
|
|
49
42
|
"next": "^16.0.0",
|
|
50
|
-
"pg": "^8.21.0",
|
|
51
43
|
"react": "^19.0.0",
|
|
52
44
|
"typescript": "^5.7.3",
|
|
53
45
|
"vitest": "^2.1.8"
|
package/src/api/handlers.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { revalidateTag } from "next/cache";
|
|
2
|
-
import
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { LinkPageInputSchema, LinkPageItemInputSchema } from "@gigamusic/core";
|
|
4
|
+
import type { LinkPageInput, LinkPageItemInput } from "@gigamusic/core";
|
|
5
|
+
import type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
|
|
3
6
|
import {
|
|
4
7
|
INVALID_SLUG_MESSAGE,
|
|
5
8
|
RESERVED_SLUGS,
|
|
@@ -24,11 +27,28 @@ export type RouteHandler<Params = Record<string, string>> = (
|
|
|
24
27
|
/**
|
|
25
28
|
* Deps for the admin-side link-page handler factories.
|
|
26
29
|
*
|
|
30
|
+
* Generic over the consumer's `QueryTables` so the `Queries` and the
|
|
31
|
+
* `afterWrite` callbacks see consumer-extended `linkPages` / `linkPageItems`
|
|
32
|
+
* row shapes when the consumer built tables via `buildLinkPages(...)` /
|
|
33
|
+
* `buildLinkPageItems(...)`.
|
|
34
|
+
*
|
|
27
35
|
* Auth: the package assumes the request has been gated by the consumer's
|
|
28
36
|
* `proxy.ts` / middleware. These handlers don't verify a session.
|
|
29
37
|
*/
|
|
30
|
-
export interface LinkPagesAdminDeps
|
|
31
|
-
|
|
38
|
+
export interface LinkPagesAdminDeps<
|
|
39
|
+
T extends QueryTables = DefaultTables,
|
|
40
|
+
TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
|
|
41
|
+
TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
|
|
42
|
+
> {
|
|
43
|
+
queries: Queries<T>;
|
|
44
|
+
/** Defaults to `LinkPageInputSchema`. Pass an `.extend()`ed schema for extras. */
|
|
45
|
+
pageInputSchema?: TPageInput;
|
|
46
|
+
/** Defaults to `LinkPageItemInputSchema`. Pass an `.extend()`ed schema for extras. */
|
|
47
|
+
itemInputSchema?: TItemInput;
|
|
48
|
+
/** Invoked after `queries.createLinkPage` / `updateLinkPage` resolves. Not tx-atomic. */
|
|
49
|
+
afterPageWrite?: (pageId: number, input: z.infer<TPageInput>) => Promise<void>;
|
|
50
|
+
/** Invoked after `queries.addLinkPageItem` / `updateLinkPageItem` resolves. Not tx-atomic. */
|
|
51
|
+
afterItemWrite?: (itemId: number, input: z.infer<TItemInput>) => Promise<void>;
|
|
32
52
|
}
|
|
33
53
|
|
|
34
54
|
function json(body: unknown, init?: ResponseInit): Response {
|
|
@@ -41,15 +61,6 @@ function json(body: unknown, init?: ResponseInit): Response {
|
|
|
41
61
|
});
|
|
42
62
|
}
|
|
43
63
|
|
|
44
|
-
interface LinkPageBody {
|
|
45
|
-
title?: string;
|
|
46
|
-
slug?: string;
|
|
47
|
-
description?: string | null;
|
|
48
|
-
releaseId?: number | null;
|
|
49
|
-
coverImageUrl?: string | null;
|
|
50
|
-
isPublished?: boolean;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
64
|
function validateSlug(slug: string): { ok: true } | { ok: false; res: Response } {
|
|
54
65
|
if (!SLUG_PATTERN.test(slug)) {
|
|
55
66
|
return { ok: false, res: json({ error: INVALID_SLUG_MESSAGE }, { status: 400 }) };
|
|
@@ -63,21 +74,30 @@ function validateSlug(slug: string): { ok: true } | { ok: false; res: Response }
|
|
|
63
74
|
/**
|
|
64
75
|
* `app/api/admin/link-pages/route.ts` handlers — list + create.
|
|
65
76
|
*/
|
|
66
|
-
export function createAdminLinkPagesHandlers
|
|
77
|
+
export function createAdminLinkPagesHandlers<
|
|
78
|
+
T extends QueryTables = DefaultTables,
|
|
79
|
+
TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
|
|
80
|
+
TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
|
|
81
|
+
>(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
|
|
67
82
|
GET: RouteHandler<Record<string, never>>;
|
|
68
83
|
POST: RouteHandler<Record<string, never>>;
|
|
69
84
|
} {
|
|
85
|
+
const pageInputSchema = (deps.pageInputSchema ??
|
|
86
|
+
LinkPageInputSchema) as TPageInput;
|
|
70
87
|
return {
|
|
71
|
-
GET: async (
|
|
72
|
-
const pages = await deps.queries.listAllLinkPages();
|
|
88
|
+
GET: async () => {
|
|
89
|
+
const pages = await deps.queries.listAllLinkPages();
|
|
73
90
|
return json(pages);
|
|
74
91
|
},
|
|
75
92
|
|
|
76
93
|
POST: async (req) => {
|
|
77
|
-
const body =
|
|
78
|
-
const
|
|
79
|
-
|
|
94
|
+
const body = await safeJson(req);
|
|
95
|
+
const parsed = pageInputSchema.safeParse(body);
|
|
96
|
+
if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
|
|
80
97
|
|
|
98
|
+
const data = parsed.data as LinkPageInput;
|
|
99
|
+
const title = data.title?.trim();
|
|
100
|
+
const slug = data.slug?.trim().toLowerCase();
|
|
81
101
|
if (!title) return json({ error: "Title is required" }, { status: 400 });
|
|
82
102
|
if (!slug) return json({ error: INVALID_SLUG_MESSAGE }, { status: 400 });
|
|
83
103
|
const slugCheck = validateSlug(slug);
|
|
@@ -87,11 +107,17 @@ const body = (await req.json()) as LinkPageBody;
|
|
|
87
107
|
const page = await deps.queries.createLinkPage({
|
|
88
108
|
title,
|
|
89
109
|
slug,
|
|
90
|
-
description:
|
|
91
|
-
releaseId:
|
|
92
|
-
coverImageUrl:
|
|
93
|
-
isPublished:
|
|
110
|
+
description: data.description ?? null,
|
|
111
|
+
releaseId: data.releaseId ?? null,
|
|
112
|
+
coverImageUrl: data.coverImageUrl ?? null,
|
|
113
|
+
isPublished: data.isPublished ?? true,
|
|
94
114
|
});
|
|
115
|
+
if (deps.afterPageWrite) {
|
|
116
|
+
await deps.afterPageWrite(
|
|
117
|
+
(page as { id: number }).id,
|
|
118
|
+
parsed.data as z.infer<TPageInput>,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
95
121
|
revalidateTag(LINK_PAGES_TAG, "max");
|
|
96
122
|
return json(page, { status: 201 });
|
|
97
123
|
} catch (err) {
|
|
@@ -107,14 +133,22 @@ const body = (await req.json()) as LinkPageBody;
|
|
|
107
133
|
/**
|
|
108
134
|
* `app/api/admin/link-pages/[id]/route.ts` handlers — single-page CRUD.
|
|
109
135
|
*/
|
|
110
|
-
export function createAdminLinkPageByIdHandlers
|
|
136
|
+
export function createAdminLinkPageByIdHandlers<
|
|
137
|
+
T extends QueryTables = DefaultTables,
|
|
138
|
+
TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
|
|
139
|
+
TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
|
|
140
|
+
>(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
|
|
111
141
|
GET: RouteHandler<{ id: string }>;
|
|
112
142
|
PUT: RouteHandler<{ id: string }>;
|
|
113
143
|
DELETE: RouteHandler<{ id: string }>;
|
|
114
144
|
} {
|
|
145
|
+
const pageInputSchema = (deps.pageInputSchema ??
|
|
146
|
+
LinkPageInputSchema) as TPageInput;
|
|
147
|
+
// Updates accept partials — make every field optional.
|
|
148
|
+
const partialPageSchema = pageInputSchema.partial();
|
|
115
149
|
return {
|
|
116
|
-
GET: async (
|
|
117
|
-
const { id: idParam } = await params;
|
|
150
|
+
GET: async (_req, { params }) => {
|
|
151
|
+
const { id: idParam } = await params;
|
|
118
152
|
const id = Number(idParam);
|
|
119
153
|
if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
|
|
120
154
|
const page = await deps.queries.getLinkPageById(id);
|
|
@@ -123,31 +157,36 @@ const { id: idParam } = await params;
|
|
|
123
157
|
},
|
|
124
158
|
|
|
125
159
|
PUT: async (req, { params }) => {
|
|
126
|
-
const { id: idParam } = await params;
|
|
160
|
+
const { id: idParam } = await params;
|
|
127
161
|
const id = Number(idParam);
|
|
128
162
|
if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
|
|
129
|
-
const body =
|
|
163
|
+
const body = await safeJson(req);
|
|
164
|
+
const parsed = partialPageSchema.safeParse(body);
|
|
165
|
+
if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
|
|
166
|
+
const data = parsed.data as Partial<LinkPageInput>;
|
|
130
167
|
|
|
131
|
-
const update:
|
|
132
|
-
if (
|
|
133
|
-
const title =
|
|
168
|
+
const update: Partial<LinkPageInput> = {};
|
|
169
|
+
if (data.title !== undefined) {
|
|
170
|
+
const title = data.title.trim();
|
|
134
171
|
if (!title) return json({ error: "Title is required" }, { status: 400 });
|
|
135
172
|
update.title = title;
|
|
136
173
|
}
|
|
137
|
-
if (
|
|
138
|
-
const slug =
|
|
174
|
+
if (data.slug !== undefined) {
|
|
175
|
+
const slug = data.slug.trim().toLowerCase();
|
|
139
176
|
const slugCheck = validateSlug(slug);
|
|
140
177
|
if (!slugCheck.ok) return slugCheck.res;
|
|
141
178
|
update.slug = slug;
|
|
142
179
|
}
|
|
143
|
-
if (
|
|
144
|
-
if (
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
if (body.isPublished !== undefined) update.isPublished = body.isPublished;
|
|
180
|
+
if (data.description !== undefined) update.description = data.description;
|
|
181
|
+
if (data.releaseId !== undefined) update.releaseId = data.releaseId;
|
|
182
|
+
if (data.coverImageUrl !== undefined) update.coverImageUrl = data.coverImageUrl;
|
|
183
|
+
if (data.isPublished !== undefined) update.isPublished = data.isPublished;
|
|
148
184
|
|
|
149
185
|
try {
|
|
150
186
|
const page = await deps.queries.updateLinkPage(id, update);
|
|
187
|
+
if (deps.afterPageWrite) {
|
|
188
|
+
await deps.afterPageWrite(id, parsed.data as z.infer<TPageInput>);
|
|
189
|
+
}
|
|
151
190
|
revalidateTag(LINK_PAGES_TAG, "max");
|
|
152
191
|
return json(page);
|
|
153
192
|
} catch (err) {
|
|
@@ -158,8 +197,8 @@ const { id: idParam } = await params;
|
|
|
158
197
|
}
|
|
159
198
|
},
|
|
160
199
|
|
|
161
|
-
DELETE: async (
|
|
162
|
-
const { id: idParam } = await params;
|
|
200
|
+
DELETE: async (_req, { params }) => {
|
|
201
|
+
const { id: idParam } = await params;
|
|
163
202
|
const id = Number(idParam);
|
|
164
203
|
if (!Number.isFinite(id)) return json({ error: "Not found" }, { status: 404 });
|
|
165
204
|
await deps.queries.deleteLinkPage(id);
|
|
@@ -184,34 +223,52 @@ interface LinkPageItemBody {
|
|
|
184
223
|
* PUT updates a single item by `itemId` in the JSON body.
|
|
185
224
|
* DELETE removes an item by `itemId` in the query string.
|
|
186
225
|
*/
|
|
187
|
-
export function createAdminLinkPageItemsHandlers
|
|
188
|
-
|
|
189
|
-
|
|
226
|
+
export function createAdminLinkPageItemsHandlers<
|
|
227
|
+
T extends QueryTables = DefaultTables,
|
|
228
|
+
TPageInput extends z.ZodObject<any> = typeof LinkPageInputSchema,
|
|
229
|
+
TItemInput extends z.ZodObject<any> = typeof LinkPageItemInputSchema,
|
|
230
|
+
>(deps: LinkPagesAdminDeps<T, TPageInput, TItemInput>): {
|
|
190
231
|
POST: RouteHandler<{ id: string }>;
|
|
191
232
|
PUT: RouteHandler<{ id: string }>;
|
|
192
233
|
DELETE: RouteHandler<{ id: string }>;
|
|
193
234
|
} {
|
|
235
|
+
const itemInputSchema = (deps.itemInputSchema ??
|
|
236
|
+
LinkPageItemInputSchema) as TItemInput;
|
|
237
|
+
const partialItemSchema = itemInputSchema.partial();
|
|
194
238
|
return {
|
|
195
239
|
POST: async (req, { params }) => {
|
|
196
|
-
const { id: pageIdParam } = await params;
|
|
240
|
+
const { id: pageIdParam } = await params;
|
|
197
241
|
const pageId = Number(pageIdParam);
|
|
198
242
|
if (!Number.isFinite(pageId)) {
|
|
199
243
|
return json({ error: "Not found" }, { status: 404 });
|
|
200
244
|
}
|
|
201
|
-
const
|
|
202
|
-
|
|
245
|
+
const body = await safeJson(req);
|
|
246
|
+
const parsed = itemInputSchema.safeParse(body);
|
|
247
|
+
if (!parsed.success) {
|
|
248
|
+
return json({ error: "Title and URL are required" }, { status: 400 });
|
|
249
|
+
}
|
|
250
|
+
const data = parsed.data as LinkPageItemInput;
|
|
251
|
+
if (!data.title?.trim() || !data.url?.trim()) {
|
|
203
252
|
return json({ error: "Title and URL are required" }, { status: 400 });
|
|
204
253
|
}
|
|
205
254
|
const item = await deps.queries.addLinkPageItem(pageId, {
|
|
206
|
-
title: title.trim(),
|
|
207
|
-
url: url.trim(),
|
|
255
|
+
title: data.title.trim(),
|
|
256
|
+
url: data.url.trim(),
|
|
257
|
+
position: data.position,
|
|
258
|
+
isVisible: data.isVisible,
|
|
208
259
|
});
|
|
260
|
+
if (deps.afterItemWrite) {
|
|
261
|
+
await deps.afterItemWrite(
|
|
262
|
+
(item as { id: number }).id,
|
|
263
|
+
parsed.data as z.infer<TItemInput>,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
209
266
|
revalidateTag(LINK_PAGES_TAG, "max");
|
|
210
267
|
return json(item, { status: 201 });
|
|
211
268
|
},
|
|
212
269
|
|
|
213
270
|
PUT: async (req) => {
|
|
214
|
-
const body = (await req
|
|
271
|
+
const body = (await safeJson(req)) as LinkPageItemBody;
|
|
215
272
|
if (body.itemId == null) {
|
|
216
273
|
return json({ error: "itemId is required" }, { status: 400 });
|
|
217
274
|
}
|
|
@@ -219,18 +276,20 @@ const body = (await req.json()) as LinkPageItemBody;
|
|
|
219
276
|
if (!Number.isFinite(itemId)) {
|
|
220
277
|
return json({ error: "itemId is required" }, { status: 400 });
|
|
221
278
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
279
|
+
// Validate the remaining body against the partial-item schema.
|
|
280
|
+
const { itemId: _itemId, ...rest } = body;
|
|
281
|
+
const parsed = partialItemSchema.safeParse(rest);
|
|
282
|
+
if (!parsed.success) return json({ error: "Validation failed" }, { status: 400 });
|
|
283
|
+
const item = await deps.queries.updateLinkPageItem(itemId, parsed.data);
|
|
284
|
+
if (deps.afterItemWrite) {
|
|
285
|
+
await deps.afterItemWrite(itemId, parsed.data as z.infer<TItemInput>);
|
|
286
|
+
}
|
|
228
287
|
revalidateTag(LINK_PAGES_TAG, "max");
|
|
229
288
|
return json(item);
|
|
230
289
|
},
|
|
231
290
|
|
|
232
291
|
DELETE: async (req) => {
|
|
233
|
-
const { searchParams } = new URL(req.url);
|
|
292
|
+
const { searchParams } = new URL(req.url);
|
|
234
293
|
const itemIdRaw = searchParams.get("itemId");
|
|
235
294
|
if (!itemIdRaw) {
|
|
236
295
|
return json({ error: "itemId is required" }, { status: 400 });
|
|
@@ -245,3 +304,11 @@ const { searchParams } = new URL(req.url);
|
|
|
245
304
|
},
|
|
246
305
|
};
|
|
247
306
|
}
|
|
307
|
+
|
|
308
|
+
async function safeJson(req: Request): Promise<unknown> {
|
|
309
|
+
try {
|
|
310
|
+
return await req.json();
|
|
311
|
+
} catch {
|
|
312
|
+
return {};
|
|
313
|
+
}
|
|
314
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
|
-
// Public entry —
|
|
2
|
-
//
|
|
1
|
+
// Public entry — platform helpers + UI, plus a convenience re-export of the
|
|
2
|
+
// link-page DB types. The queries themselves come from `@gigamusic/db`'s
|
|
3
|
+
// `createQueries(db, site)`; admin handler factories live on `./server`.
|
|
3
4
|
|
|
4
|
-
// ── Types
|
|
5
|
+
// ── Types (re-exported from @gigamusic/db, the single source for DB types) ─
|
|
5
6
|
export type {
|
|
6
7
|
LinkPage,
|
|
7
8
|
LinkPageItem,
|
|
8
9
|
LinkPageReleaseRef,
|
|
9
10
|
LinkPageWithItems,
|
|
10
|
-
LinkPageQueries,
|
|
11
11
|
LinkPageInput,
|
|
12
12
|
LinkPageItemInput,
|
|
13
|
-
} from "
|
|
14
|
-
|
|
15
|
-
// ── Queries factory ─────────────────────────────────────────────────────
|
|
16
|
-
export { createLinkPageQueries } from "./queries/link-pages";
|
|
17
|
-
export type { LinkPagesDb } from "./queries/link-pages";
|
|
13
|
+
} from "@gigamusic/db";
|
|
18
14
|
|
|
19
15
|
// ── Platform detection ──────────────────────────────────────────────────
|
|
20
16
|
export { detectLinkPlatform, PLATFORM_LABELS } from "./platforms/detect";
|
package/src/server.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// Server-only entry — admin API handler factories
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Server-only entry — admin API handler factories. Importing from here in a
|
|
2
|
+
// `"use client"` file will cause a build error because the handlers depend on
|
|
3
|
+
// `next/cache`. (The query factory lives in `@gigamusic/db`'s `createQueries`.)
|
|
4
4
|
|
|
5
|
-
export { createLinkPageQueries } from "./queries/link-pages";
|
|
6
|
-
export type { LinkPagesDb } from "./queries/link-pages";
|
|
7
5
|
export {
|
|
8
6
|
createAdminLinkPagesHandlers,
|
|
9
7
|
createAdminLinkPageByIdHandlers,
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import { and, asc, desc, eq, max } from "drizzle-orm";
|
|
2
|
-
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
-
import type * as gigamusicDbSchema from "@gigamusic/db/schema";
|
|
4
|
-
import type * as linksSchema from "../schema/index";
|
|
5
|
-
import { linkPageItems, linkPages } from "../schema/index";
|
|
6
|
-
import type {
|
|
7
|
-
LinkPage,
|
|
8
|
-
LinkPageInput,
|
|
9
|
-
LinkPageItem,
|
|
10
|
-
LinkPageItemInput,
|
|
11
|
-
LinkPageQueries,
|
|
12
|
-
LinkPageWithItems,
|
|
13
|
-
} from "../types";
|
|
14
|
-
|
|
15
|
-
type RequiredSchema = typeof gigamusicDbSchema & typeof linksSchema;
|
|
16
|
-
|
|
17
|
-
/** Drizzle DB type the link-pages factory needs — a superset that includes both link-pages tables and `releases`. */
|
|
18
|
-
export type LinkPagesDb = NodePgDatabase<RequiredSchema>;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Factory that binds a Drizzle database into the `LinkPageQueries` contract.
|
|
22
|
-
* Consumers typically merge the result with `@gigamusic/db`'s `createQueries`.
|
|
23
|
-
*
|
|
24
|
-
* The `db` instance must have been built with a schema that includes the
|
|
25
|
-
* union of `@gigamusic/db/schema` and `@gigamusic/links/schema`; otherwise
|
|
26
|
-
* the relational `with: { release: ... }` clause won't resolve at runtime.
|
|
27
|
-
*/
|
|
28
|
-
export function createLinkPageQueries(db: LinkPagesDb): LinkPageQueries {
|
|
29
|
-
return {
|
|
30
|
-
listPublishedLinkPages: async (): Promise<LinkPage[]> => {
|
|
31
|
-
const rows = await db.query.linkPages.findMany({
|
|
32
|
-
where: eq(linkPages.isPublished, true),
|
|
33
|
-
orderBy: desc(linkPages.updatedAt),
|
|
34
|
-
});
|
|
35
|
-
return rows;
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
listAllLinkPages: async (): Promise<LinkPage[]> => {
|
|
39
|
-
const rows = await db.query.linkPages.findMany({
|
|
40
|
-
orderBy: desc(linkPages.updatedAt),
|
|
41
|
-
});
|
|
42
|
-
return rows;
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
getPublicLinkPageBySlug: async (slug: string): Promise<LinkPageWithItems | null> => {
|
|
46
|
-
const row = await db.query.linkPages.findFirst({
|
|
47
|
-
where: and(eq(linkPages.slug, slug), eq(linkPages.isPublished, true)),
|
|
48
|
-
with: {
|
|
49
|
-
release: {
|
|
50
|
-
columns: {
|
|
51
|
-
id: true,
|
|
52
|
-
name: true,
|
|
53
|
-
slug: true,
|
|
54
|
-
coverImageUrl: true,
|
|
55
|
-
isPublished: true,
|
|
56
|
-
},
|
|
57
|
-
},
|
|
58
|
-
items: {
|
|
59
|
-
where: eq(linkPageItems.isVisible, true),
|
|
60
|
-
orderBy: asc(linkPageItems.position),
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
return (row ?? null) as unknown as LinkPageWithItems | null;
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
getLinkPageById: async (id: number): Promise<LinkPageWithItems | null> => {
|
|
68
|
-
const row = await db.query.linkPages.findFirst({
|
|
69
|
-
where: eq(linkPages.id, id),
|
|
70
|
-
with: {
|
|
71
|
-
release: {
|
|
72
|
-
columns: {
|
|
73
|
-
id: true,
|
|
74
|
-
name: true,
|
|
75
|
-
slug: true,
|
|
76
|
-
coverImageUrl: true,
|
|
77
|
-
isPublished: true,
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
items: { orderBy: asc(linkPageItems.position) },
|
|
81
|
-
},
|
|
82
|
-
});
|
|
83
|
-
return (row ?? null) as unknown as LinkPageWithItems | null;
|
|
84
|
-
},
|
|
85
|
-
|
|
86
|
-
createLinkPage: async (input: LinkPageInput): Promise<LinkPage> => {
|
|
87
|
-
const [row] = await db
|
|
88
|
-
.insert(linkPages)
|
|
89
|
-
.values({
|
|
90
|
-
title: input.title,
|
|
91
|
-
slug: input.slug,
|
|
92
|
-
description: input.description ?? null,
|
|
93
|
-
releaseId: input.releaseId ?? null,
|
|
94
|
-
coverImageUrl: input.coverImageUrl ?? null,
|
|
95
|
-
isPublished: input.isPublished ?? true,
|
|
96
|
-
})
|
|
97
|
-
.returning();
|
|
98
|
-
return row!;
|
|
99
|
-
},
|
|
100
|
-
|
|
101
|
-
updateLinkPage: async (id: number, input: Partial<LinkPageInput>): Promise<LinkPage> => {
|
|
102
|
-
const data: Record<string, unknown> = {};
|
|
103
|
-
if (input.title !== undefined) data.title = input.title;
|
|
104
|
-
if (input.slug !== undefined) data.slug = input.slug;
|
|
105
|
-
if (input.description !== undefined) data.description = input.description;
|
|
106
|
-
if (input.releaseId !== undefined) data.releaseId = input.releaseId;
|
|
107
|
-
if (input.coverImageUrl !== undefined) data.coverImageUrl = input.coverImageUrl;
|
|
108
|
-
if (input.isPublished !== undefined) data.isPublished = input.isPublished;
|
|
109
|
-
const [row] = await db.update(linkPages).set(data).where(eq(linkPages.id, id)).returning();
|
|
110
|
-
return row!;
|
|
111
|
-
},
|
|
112
|
-
|
|
113
|
-
deleteLinkPage: async (id: number): Promise<void> => {
|
|
114
|
-
await db.delete(linkPages).where(eq(linkPages.id, id));
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
addLinkPageItem: async (pageId: number, input: LinkPageItemInput): Promise<LinkPageItem> => {
|
|
118
|
-
const [maxRow] = await db
|
|
119
|
-
.select({ max: max(linkPageItems.position) })
|
|
120
|
-
.from(linkPageItems)
|
|
121
|
-
.where(eq(linkPageItems.pageId, pageId));
|
|
122
|
-
const nextPosition = input.position ?? ((maxRow?.max ?? -1) + 1);
|
|
123
|
-
const [row] = await db
|
|
124
|
-
.insert(linkPageItems)
|
|
125
|
-
.values({
|
|
126
|
-
pageId,
|
|
127
|
-
title: input.title,
|
|
128
|
-
url: input.url,
|
|
129
|
-
position: nextPosition,
|
|
130
|
-
isVisible: input.isVisible ?? true,
|
|
131
|
-
})
|
|
132
|
-
.returning();
|
|
133
|
-
return row!;
|
|
134
|
-
},
|
|
135
|
-
|
|
136
|
-
updateLinkPageItem: async (
|
|
137
|
-
id: number,
|
|
138
|
-
input: Partial<LinkPageItemInput>,
|
|
139
|
-
): Promise<LinkPageItem> => {
|
|
140
|
-
const data: Record<string, unknown> = {};
|
|
141
|
-
if (input.title !== undefined) data.title = input.title;
|
|
142
|
-
if (input.url !== undefined) data.url = input.url;
|
|
143
|
-
if (input.position !== undefined) data.position = input.position;
|
|
144
|
-
if (input.isVisible !== undefined) data.isVisible = input.isVisible;
|
|
145
|
-
const [row] = await db.update(linkPageItems).set(data).where(eq(linkPageItems.id, id)).returning();
|
|
146
|
-
return row!;
|
|
147
|
-
},
|
|
148
|
-
|
|
149
|
-
deleteLinkPageItem: async (id: number): Promise<void> => {
|
|
150
|
-
await db.delete(linkPageItems).where(eq(linkPageItems.id, id));
|
|
151
|
-
},
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Rewrite every item's `position` to match the caller's preferred order
|
|
155
|
-
* inside a single transaction. IDs not present in `orderedItemIds` keep
|
|
156
|
-
* their current position — callers must include the full set to fully
|
|
157
|
-
* resort.
|
|
158
|
-
*/
|
|
159
|
-
reorderLinkPageItems: async (pageId: number, orderedItemIds: number[]): Promise<void> => {
|
|
160
|
-
await db.transaction(async (tx) => {
|
|
161
|
-
for (let position = 0; position < orderedItemIds.length; position++) {
|
|
162
|
-
await tx
|
|
163
|
-
.update(linkPageItems)
|
|
164
|
-
.set({ position })
|
|
165
|
-
.where(and(eq(linkPageItems.id, orderedItemIds[position]!), eq(linkPageItems.pageId, pageId)));
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
}
|
package/src/schema/index.ts
DELETED
package/src/schema/link-pages.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { boolean, index, integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
-
import { releases } from "@gigamusic/db/schema";
|
|
3
|
-
|
|
4
|
-
export const linkPageColumns = {
|
|
5
|
-
id: serial("id").primaryKey(),
|
|
6
|
-
slug: text("slug").notNull().unique("link_pages_slug_key"),
|
|
7
|
-
title: text("title").notNull(),
|
|
8
|
-
description: text("description"),
|
|
9
|
-
coverImageUrl: text("coverImageUrl"),
|
|
10
|
-
releaseId: integer("releaseId").references(() => releases.id, { onDelete: "set null" }),
|
|
11
|
-
isPublished: boolean("isPublished").notNull().default(true),
|
|
12
|
-
createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
|
|
13
|
-
updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
|
|
14
|
-
.notNull()
|
|
15
|
-
.$defaultFn(() => new Date())
|
|
16
|
-
.$onUpdateFn(() => new Date()),
|
|
17
|
-
} as const;
|
|
18
|
-
|
|
19
|
-
export const linkPages = pgTable("link_pages", linkPageColumns, (t) => [
|
|
20
|
-
index("link_pages_releaseId_idx").on(t.releaseId),
|
|
21
|
-
]);
|
|
22
|
-
|
|
23
|
-
export const linkPageItemColumns = {
|
|
24
|
-
id: serial("id").primaryKey(),
|
|
25
|
-
pageId: integer("pageId")
|
|
26
|
-
.notNull()
|
|
27
|
-
.references(() => linkPages.id, { onDelete: "cascade" }),
|
|
28
|
-
title: text("title").notNull(),
|
|
29
|
-
url: text("url").notNull(),
|
|
30
|
-
position: integer("position").notNull().default(0),
|
|
31
|
-
isVisible: boolean("isVisible").notNull().default(true),
|
|
32
|
-
createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).notNull().defaultNow(),
|
|
33
|
-
updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 })
|
|
34
|
-
.notNull()
|
|
35
|
-
.$defaultFn(() => new Date())
|
|
36
|
-
.$onUpdateFn(() => new Date()),
|
|
37
|
-
} as const;
|
|
38
|
-
|
|
39
|
-
export const linkPageItems = pgTable("link_page_items", linkPageItemColumns, (t) => [
|
|
40
|
-
index("link_page_items_pageId_idx").on(t.pageId),
|
|
41
|
-
]);
|
package/src/schema/relations.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { relations } from "drizzle-orm";
|
|
2
|
-
import { releases } from "@gigamusic/db/schema";
|
|
3
|
-
import { linkPages, linkPageItems } from "./link-pages";
|
|
4
|
-
|
|
5
|
-
export const linkPagesRelations = relations(linkPages, ({ one, many }) => ({
|
|
6
|
-
release: one(releases, { fields: [linkPages.releaseId], references: [releases.id] }),
|
|
7
|
-
items: many(linkPageItems),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
export const linkPageItemsRelations = relations(linkPageItems, ({ one }) => ({
|
|
11
|
-
page: one(linkPages, { fields: [linkPageItems.pageId], references: [linkPages.id] }),
|
|
12
|
-
}));
|
package/src/types.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import type { LinkPageInput, LinkPageItemInput } from "@gigamusic/core";
|
|
2
|
-
import type { linkPageItems, linkPages } from "./schema/index";
|
|
3
|
-
|
|
4
|
-
export type { LinkPageInput, LinkPageItemInput };
|
|
5
|
-
|
|
6
|
-
/** A single link button rendered inside a `LinkPage`. */
|
|
7
|
-
export type LinkPageItem = typeof linkPageItems.$inferSelect;
|
|
8
|
-
|
|
9
|
-
/** Optional `Release` reference exposed to the public view for cover fallback. */
|
|
10
|
-
export interface LinkPageReleaseRef {
|
|
11
|
-
id: number;
|
|
12
|
-
name: string;
|
|
13
|
-
slug: string;
|
|
14
|
-
coverImageUrl: string | null;
|
|
15
|
-
isPublished: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Top-level "Linktree-style" page row. */
|
|
19
|
-
export type LinkPage = typeof linkPages.$inferSelect;
|
|
20
|
-
|
|
21
|
-
/** A `LinkPage` joined with its visible (or all) items and an optional release ref. */
|
|
22
|
-
export interface LinkPageWithItems extends LinkPage {
|
|
23
|
-
items: LinkPageItem[];
|
|
24
|
-
release?: LinkPageReleaseRef | null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Query helpers that close over a Drizzle DB. Consumers merge this with
|
|
29
|
-
* `@gigamusic/db`'s `Queries` factory result (or pass it through wherever a
|
|
30
|
-
* `LinkPageQueries` is needed).
|
|
31
|
-
*/
|
|
32
|
-
export interface LinkPageQueries {
|
|
33
|
-
listPublishedLinkPages(): Promise<LinkPage[]>;
|
|
34
|
-
listAllLinkPages(): Promise<LinkPage[]>;
|
|
35
|
-
getPublicLinkPageBySlug(slug: string): Promise<LinkPageWithItems | null>;
|
|
36
|
-
getLinkPageById(id: number): Promise<LinkPageWithItems | null>;
|
|
37
|
-
createLinkPage(input: LinkPageInput): Promise<LinkPage>;
|
|
38
|
-
updateLinkPage(id: number, input: Partial<LinkPageInput>): Promise<LinkPage>;
|
|
39
|
-
deleteLinkPage(id: number): Promise<void>;
|
|
40
|
-
addLinkPageItem(pageId: number, input: LinkPageItemInput): Promise<LinkPageItem>;
|
|
41
|
-
updateLinkPageItem(
|
|
42
|
-
id: number,
|
|
43
|
-
input: Partial<LinkPageItemInput>,
|
|
44
|
-
): Promise<LinkPageItem>;
|
|
45
|
-
deleteLinkPageItem(id: number): Promise<void>;
|
|
46
|
-
reorderLinkPageItems(pageId: number, orderedItemIds: number[]): Promise<void>;
|
|
47
|
-
}
|