@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,175 @@
|
|
|
1
|
+
import type { PrismaClient } from "@prisma/client";
|
|
2
|
+
import type {
|
|
3
|
+
LinkPage,
|
|
4
|
+
LinkPageInput,
|
|
5
|
+
LinkPageItem,
|
|
6
|
+
LinkPageItemInput,
|
|
7
|
+
LinkPageQueries,
|
|
8
|
+
LinkPageWithItems,
|
|
9
|
+
} from "../types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Factory that binds a `PrismaClient` into the `LinkPageQueries` contract.
|
|
13
|
+
* Consumers typically merge the result with `@gigamusic/db`'s `createQueries`.
|
|
14
|
+
*
|
|
15
|
+
* Both inputs and outputs use numeric IDs, matching the underlying
|
|
16
|
+
* `Int @id @default(autoincrement())` schema fragments.
|
|
17
|
+
*/
|
|
18
|
+
export function createLinkPageQueries(db: PrismaClient): LinkPageQueries {
|
|
19
|
+
return {
|
|
20
|
+
listPublishedLinkPages: async (): Promise<LinkPage[]> => {
|
|
21
|
+
const rows = await db.linkPage.findMany({
|
|
22
|
+
where: { isPublished: true },
|
|
23
|
+
orderBy: { updatedAt: "desc" },
|
|
24
|
+
});
|
|
25
|
+
return rows as unknown as LinkPage[];
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
listAllLinkPages: async (): Promise<LinkPage[]> => {
|
|
29
|
+
const rows = await db.linkPage.findMany({
|
|
30
|
+
orderBy: { updatedAt: "desc" },
|
|
31
|
+
});
|
|
32
|
+
return rows as unknown as LinkPage[];
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
getPublicLinkPageBySlug: async (
|
|
36
|
+
slug: string,
|
|
37
|
+
): Promise<LinkPageWithItems | null> => {
|
|
38
|
+
const row = await db.linkPage.findFirst({
|
|
39
|
+
where: { slug, isPublished: true },
|
|
40
|
+
include: {
|
|
41
|
+
release: {
|
|
42
|
+
select: {
|
|
43
|
+
id: true,
|
|
44
|
+
name: true,
|
|
45
|
+
slug: true,
|
|
46
|
+
coverImageUrl: true,
|
|
47
|
+
isPublished: true,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
items: {
|
|
51
|
+
where: { isVisible: true },
|
|
52
|
+
orderBy: { position: "asc" },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
return row as unknown as LinkPageWithItems | null;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
getLinkPageById: async (id: number): Promise<LinkPageWithItems | null> => {
|
|
60
|
+
const row = await db.linkPage.findUnique({
|
|
61
|
+
where: { id },
|
|
62
|
+
include: {
|
|
63
|
+
release: {
|
|
64
|
+
select: {
|
|
65
|
+
id: true,
|
|
66
|
+
name: true,
|
|
67
|
+
slug: true,
|
|
68
|
+
coverImageUrl: true,
|
|
69
|
+
isPublished: true,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
items: { orderBy: { position: "asc" } },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
return row as unknown as LinkPageWithItems | null;
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
createLinkPage: async (input: LinkPageInput): Promise<LinkPage> => {
|
|
79
|
+
const row = await db.linkPage.create({
|
|
80
|
+
data: {
|
|
81
|
+
title: input.title,
|
|
82
|
+
slug: input.slug,
|
|
83
|
+
description: input.description ?? null,
|
|
84
|
+
releaseId: input.releaseId ?? null,
|
|
85
|
+
coverImageUrl: input.coverImageUrl ?? null,
|
|
86
|
+
isPublished: input.isPublished ?? true,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
return row as unknown as LinkPage;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
updateLinkPage: async (
|
|
93
|
+
id: number,
|
|
94
|
+
input: Partial<LinkPageInput>,
|
|
95
|
+
): Promise<LinkPage> => {
|
|
96
|
+
const data: Record<string, unknown> = {};
|
|
97
|
+
if (input.title !== undefined) data.title = input.title;
|
|
98
|
+
if (input.slug !== undefined) data.slug = input.slug;
|
|
99
|
+
if (input.description !== undefined) data.description = input.description;
|
|
100
|
+
if (input.releaseId !== undefined) data.releaseId = input.releaseId;
|
|
101
|
+
if (input.coverImageUrl !== undefined)
|
|
102
|
+
data.coverImageUrl = input.coverImageUrl;
|
|
103
|
+
if (input.isPublished !== undefined) data.isPublished = input.isPublished;
|
|
104
|
+
const row = await db.linkPage.update({
|
|
105
|
+
where: { id },
|
|
106
|
+
data,
|
|
107
|
+
});
|
|
108
|
+
return row as unknown as LinkPage;
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
deleteLinkPage: async (id: number): Promise<void> => {
|
|
112
|
+
await db.linkPage.delete({ where: { id } });
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
addLinkPageItem: async (
|
|
116
|
+
pageId: number,
|
|
117
|
+
input: LinkPageItemInput,
|
|
118
|
+
): Promise<LinkPageItem> => {
|
|
119
|
+
const max = await db.linkPageItem.aggregate({
|
|
120
|
+
where: { pageId },
|
|
121
|
+
_max: { position: true },
|
|
122
|
+
});
|
|
123
|
+
const row = await db.linkPageItem.create({
|
|
124
|
+
data: {
|
|
125
|
+
pageId,
|
|
126
|
+
title: input.title,
|
|
127
|
+
url: input.url,
|
|
128
|
+
position: input.position ?? (max._max.position ?? -1) + 1,
|
|
129
|
+
isVisible: input.isVisible ?? true,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
return row as unknown as LinkPageItem;
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
updateLinkPageItem: async (
|
|
136
|
+
id: number,
|
|
137
|
+
input: Partial<LinkPageItemInput>,
|
|
138
|
+
): Promise<LinkPageItem> => {
|
|
139
|
+
const data: Record<string, unknown> = {};
|
|
140
|
+
if (input.title !== undefined) data.title = input.title;
|
|
141
|
+
if (input.url !== undefined) data.url = input.url;
|
|
142
|
+
if (input.position !== undefined) data.position = input.position;
|
|
143
|
+
if (input.isVisible !== undefined) data.isVisible = input.isVisible;
|
|
144
|
+
const row = await db.linkPageItem.update({
|
|
145
|
+
where: { id },
|
|
146
|
+
data,
|
|
147
|
+
});
|
|
148
|
+
return row as unknown as LinkPageItem;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
deleteLinkPageItem: async (id: number): Promise<void> => {
|
|
152
|
+
await db.linkPageItem.delete({ where: { id } });
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Rewrite every item's `position` to match the caller's preferred order
|
|
157
|
+
* inside a single transaction. IDs not present in `orderedItemIds` keep
|
|
158
|
+
* their current position — callers must include the full set to fully
|
|
159
|
+
* resort.
|
|
160
|
+
*/
|
|
161
|
+
reorderLinkPageItems: async (
|
|
162
|
+
pageId: number,
|
|
163
|
+
orderedItemIds: number[],
|
|
164
|
+
): Promise<void> => {
|
|
165
|
+
await db.$transaction(
|
|
166
|
+
orderedItemIds.map((itemId, position) =>
|
|
167
|
+
db.linkPageItem.update({
|
|
168
|
+
where: { id: itemId, pageId },
|
|
169
|
+
data: { position },
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Server-only entry — admin API handler factories and the queries factory.
|
|
2
|
+
// Importing from here in a `"use client"` file will cause a build error
|
|
3
|
+
// because the handlers depend on `next/cache`.
|
|
4
|
+
|
|
5
|
+
export { createLinkPageQueries } from "./queries/link-pages";
|
|
6
|
+
export {
|
|
7
|
+
createAdminLinkPagesHandlers,
|
|
8
|
+
createAdminLinkPageByIdHandlers,
|
|
9
|
+
createAdminLinkPageItemsHandlers,
|
|
10
|
+
LINK_PAGES_TAG,
|
|
11
|
+
} from "./api/handlers";
|
|
12
|
+
export type {
|
|
13
|
+
LinkPagesAdminDeps,
|
|
14
|
+
RouteHandler,
|
|
15
|
+
} from "./api/handlers";
|
|
16
|
+
export {
|
|
17
|
+
PublicLinkPage,
|
|
18
|
+
createPublicLinkPage,
|
|
19
|
+
createPublicLinkPageMetadata,
|
|
20
|
+
publicLinkPageMetadata,
|
|
21
|
+
registerLinkPageMetadata,
|
|
22
|
+
} from "./pages/PublicLinkPage";
|
|
@@ -0,0 +1,46 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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 };
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { LinkPageInput, LinkPageItemInput } from "@gigamusic/core";
|
|
2
|
+
|
|
3
|
+
export type { LinkPageInput, LinkPageItemInput };
|
|
4
|
+
|
|
5
|
+
/** A single link button rendered inside a `LinkPage`. */
|
|
6
|
+
export interface LinkPageItem {
|
|
7
|
+
id: number;
|
|
8
|
+
pageId: number;
|
|
9
|
+
title: string;
|
|
10
|
+
url: string;
|
|
11
|
+
position: number;
|
|
12
|
+
isVisible: boolean;
|
|
13
|
+
createdAt: Date;
|
|
14
|
+
updatedAt: Date;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Optional `Release` reference exposed to the public view for cover fallback. */
|
|
18
|
+
export interface LinkPageReleaseRef {
|
|
19
|
+
id: number;
|
|
20
|
+
name: string;
|
|
21
|
+
slug: string;
|
|
22
|
+
coverImageUrl: string | null;
|
|
23
|
+
isPublished: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Top-level "Linktree-style" page row. IDs are native `Int @id @default(autoincrement())`. */
|
|
27
|
+
export interface LinkPage {
|
|
28
|
+
id: number;
|
|
29
|
+
slug: string;
|
|
30
|
+
title: string;
|
|
31
|
+
description: string | null;
|
|
32
|
+
coverImageUrl: string | null;
|
|
33
|
+
releaseId: number | null;
|
|
34
|
+
isPublished: boolean;
|
|
35
|
+
createdAt: Date;
|
|
36
|
+
updatedAt: Date;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A `LinkPage` joined with its visible (or all) items and an optional release ref. */
|
|
40
|
+
export interface LinkPageWithItems extends LinkPage {
|
|
41
|
+
items: LinkPageItem[];
|
|
42
|
+
release?: LinkPageReleaseRef | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Query helpers that close over a `PrismaClient`. Consumers merge this with
|
|
47
|
+
* `@gigamusic/db`'s `Queries` factory result (or pass it through wherever a
|
|
48
|
+
* `LinkPageQueries` is needed).
|
|
49
|
+
*/
|
|
50
|
+
export interface LinkPageQueries {
|
|
51
|
+
listPublishedLinkPages(): Promise<LinkPage[]>;
|
|
52
|
+
listAllLinkPages(): Promise<LinkPage[]>;
|
|
53
|
+
getPublicLinkPageBySlug(slug: string): Promise<LinkPageWithItems | null>;
|
|
54
|
+
getLinkPageById(id: number): Promise<LinkPageWithItems | null>;
|
|
55
|
+
createLinkPage(input: LinkPageInput): Promise<LinkPage>;
|
|
56
|
+
updateLinkPage(id: number, input: Partial<LinkPageInput>): Promise<LinkPage>;
|
|
57
|
+
deleteLinkPage(id: number): Promise<void>;
|
|
58
|
+
addLinkPageItem(pageId: number, input: LinkPageItemInput): Promise<LinkPageItem>;
|
|
59
|
+
updateLinkPageItem(
|
|
60
|
+
id: number,
|
|
61
|
+
input: Partial<LinkPageItemInput>,
|
|
62
|
+
): Promise<LinkPageItem>;
|
|
63
|
+
deleteLinkPageItem(id: number): Promise<void>;
|
|
64
|
+
reorderLinkPageItems(pageId: number, orderedItemIds: number[]): Promise<void>;
|
|
65
|
+
}
|