@terrafirmagreg-team/discussions 0.0.0-stage → 1.2.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/package.json +56 -3
- package/src/components/comments-view.tsx +32 -0
- package/src/components/comments.tsx +8 -0
- package/src/components/feedback/block.tsx +366 -0
- package/src/components/feedback/page.tsx +181 -0
- package/src/hooks/comments/use-enabled.ts +37 -0
- package/src/hooks/comments/use-menu-item.ts +29 -0
- package/src/hooks/comments/use-session.ts +21 -0
- package/src/hooks/comments/use-thread.ts +11 -0
- package/src/hooks/feedback/use-submission-storage.ts +32 -0
- package/src/lib/comments/auth.ts +14 -0
- package/src/lib/comments/client.ts +29 -0
- package/src/lib/comments/github.ts +35 -0
- package/src/lib/comments/local.ts +168 -0
- package/src/lib/comments/server.ts +204 -0
- package/src/lib/comments.ts +18 -0
- package/src/lib/config.ts +27 -0
- package/src/lib/feedback-schema.ts +24 -0
- package/src/lib/feedback.ts +74 -0
- package/src/locales/en_us.json +13 -0
- package/src/locales/ru_ru.json +13 -0
- package/src/locales.ts +6 -0
- package/README.md +0 -3
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createElement } from 'react';
|
|
4
|
+
import { LogIn, LogOut } from 'lucide-react';
|
|
5
|
+
import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
|
|
6
|
+
import { useCommentsEnabled } from './use-enabled';
|
|
7
|
+
import { useCommentsSession } from './use-session';
|
|
8
|
+
import {
|
|
9
|
+
redirectToCommentsLogin,
|
|
10
|
+
redirectToCommentsLogout,
|
|
11
|
+
} from '../../lib/comments/auth';
|
|
12
|
+
import type { SiteMenuItem } from '@terrafirmagreg-team/ui/components/blocks/nav/site-menu';
|
|
13
|
+
|
|
14
|
+
const COMMENTS_API = '/api/comments';
|
|
15
|
+
|
|
16
|
+
export function useCommentsMenuItem(): SiteMenuItem | false {
|
|
17
|
+
const t = useTranslations();
|
|
18
|
+
const enabled = useCommentsEnabled(`${COMMENTS_API}/config`);
|
|
19
|
+
const { session } = useCommentsSession(COMMENTS_API, { enabled });
|
|
20
|
+
if (!enabled) return false;
|
|
21
|
+
return {
|
|
22
|
+
label: session ? t('Sign out', { note: 'ui.sign_out' }) : t('Sign in', { note: 'ui.sign_in' }),
|
|
23
|
+
icon: session ? createElement(LogOut) : createElement(LogIn),
|
|
24
|
+
onClick: session
|
|
25
|
+
? () => redirectToCommentsLogout(COMMENTS_API)
|
|
26
|
+
: () => redirectToCommentsLogin(COMMENTS_API),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import useSWRImmutable from 'swr/immutable';
|
|
5
|
+
import type { AuthInfoWithRole } from '@fuma-comment/server';
|
|
6
|
+
import { createCommentsClient } from '../../lib/comments/client';
|
|
7
|
+
import { usePageThread } from './use-thread';
|
|
8
|
+
|
|
9
|
+
export function useCommentsSession(
|
|
10
|
+
apiUrl = '/api/comments',
|
|
11
|
+
options?: { page?: string; enabled?: boolean },
|
|
12
|
+
): { session: AuthInfoWithRole | null; isLoading: boolean } {
|
|
13
|
+
const thread = usePageThread(options?.page);
|
|
14
|
+
const client = useMemo(() => createCommentsClient(apiUrl), [apiUrl]);
|
|
15
|
+
const query = useSWRImmutable(
|
|
16
|
+
(options?.enabled ?? true) ? ['comments-session', thread] : null,
|
|
17
|
+
() => client.getAuthSession({ page: thread }).catch(() => null),
|
|
18
|
+
);
|
|
19
|
+
return { session: query.data ?? null, isLoading: query.isLoading };
|
|
20
|
+
}
|
|
21
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { usePathname } from 'next/navigation';
|
|
4
|
+
import { useResolvedLang } from '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
|
|
5
|
+
|
|
6
|
+
export function usePageThread(page?: string): string {
|
|
7
|
+
const pathname = usePathname();
|
|
8
|
+
const lang = useResolvedLang();
|
|
9
|
+
return (page ?? `${pathname}~${lang}`).replaceAll(/[/#]/g, '~');
|
|
10
|
+
}
|
|
11
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useEffectEvent, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export function useSubmissionStorage<Result>(
|
|
6
|
+
key: string,
|
|
7
|
+
validate: (v: unknown) => Result | null,
|
|
8
|
+
) {
|
|
9
|
+
const storageKey = `docs-feedback-${key}`;
|
|
10
|
+
const [value, setValue] = useState<Result | null>(null);
|
|
11
|
+
const validateCallback = useEffectEvent(validate);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const item = localStorage.getItem(storageKey);
|
|
15
|
+
if (item === null) return;
|
|
16
|
+
const validated = validateCallback(JSON.parse(item));
|
|
17
|
+
|
|
18
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate persisted submission once per key
|
|
19
|
+
if (validated !== null) setValue(validated);
|
|
20
|
+
}, [storageKey]);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
previous: value,
|
|
24
|
+
setPrevious(result: Result | null) {
|
|
25
|
+
if (result) localStorage.setItem(storageKey, JSON.stringify(result));
|
|
26
|
+
else localStorage.removeItem(storageKey);
|
|
27
|
+
|
|
28
|
+
setValue(result);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
export function redirectToCommentsLogin(apiUrl: string): void {
|
|
4
|
+
const login = new URL(`${apiUrl}/oauth/login`, window.location.origin);
|
|
5
|
+
login.searchParams.set('return', window.location.href);
|
|
6
|
+
window.location.href = login.href;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function redirectToCommentsLogout(apiUrl: string): void {
|
|
10
|
+
const logout = new URL(`${apiUrl}/oauth/logout`, window.location.origin);
|
|
11
|
+
logout.searchParams.set('return', window.location.href);
|
|
12
|
+
window.location.href = logout.href;
|
|
13
|
+
}
|
|
14
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import type { AuthInfoWithRole } from '@fuma-comment/server';
|
|
3
|
+
|
|
4
|
+
export type CommentsClient = ReturnType<typeof createCommentsClient>;
|
|
5
|
+
|
|
6
|
+
export function createCommentsClient(apiUrl = '/api/comments') {
|
|
7
|
+
async function fetcher<T = void>(url: string, init?: RequestInit): Promise<T> {
|
|
8
|
+
const response = await fetch(url, init);
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
const message = await response.text();
|
|
11
|
+
let err = new Error(message);
|
|
12
|
+
try {
|
|
13
|
+
const obj = JSON.parse(message) as object;
|
|
14
|
+
if ('message' in obj && typeof obj.message === 'string') {
|
|
15
|
+
err = new Error(obj.message);
|
|
16
|
+
}
|
|
17
|
+
} catch {}
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
return (await response.json()) as T;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
getAuthSession(options: { page: string }): Promise<AuthInfoWithRole> {
|
|
25
|
+
return fetcher(`${apiUrl}/${options.page}/auth`);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { githubGraphql } from '@terrafirmagreg-team/data';
|
|
3
|
+
import type { GDiscussion } from 'fuma-comment-github-discussions';
|
|
4
|
+
|
|
5
|
+
const CREATE_DISCUSSION = `
|
|
6
|
+
mutation ($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
|
|
7
|
+
createDiscussion(
|
|
8
|
+
input: { repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body }
|
|
9
|
+
) {
|
|
10
|
+
discussion { id url }
|
|
11
|
+
}
|
|
12
|
+
}`;
|
|
13
|
+
|
|
14
|
+
export async function createDiscussion(
|
|
15
|
+
target: { repoId: string; categoryId: string },
|
|
16
|
+
title: string,
|
|
17
|
+
url: string | undefined,
|
|
18
|
+
token: string,
|
|
19
|
+
): Promise<GDiscussion> {
|
|
20
|
+
const link = url ? ` ([${title}](${url}))` : '';
|
|
21
|
+
const data = await githubGraphql<{
|
|
22
|
+
createDiscussion: { discussion: { id: string; url: string } };
|
|
23
|
+
}>(
|
|
24
|
+
CREATE_DISCUSSION,
|
|
25
|
+
{
|
|
26
|
+
repositoryId: target.repoId,
|
|
27
|
+
categoryId: target.categoryId,
|
|
28
|
+
title,
|
|
29
|
+
body: `Comment thread for \`${title}\`${link}.`,
|
|
30
|
+
},
|
|
31
|
+
{ token },
|
|
32
|
+
);
|
|
33
|
+
return data.createDiscussion.discussion as GDiscussion;
|
|
34
|
+
}
|
|
35
|
+
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
Comment,
|
|
6
|
+
StorageAdapter,
|
|
7
|
+
UserProfile,
|
|
8
|
+
} from '@fuma-comment/server';
|
|
9
|
+
import { env } from '@terrafirmagreg-team/data';
|
|
10
|
+
|
|
11
|
+
interface LocalComment {
|
|
12
|
+
id: string;
|
|
13
|
+
page: string;
|
|
14
|
+
threadId?: string;
|
|
15
|
+
author: UserProfile;
|
|
16
|
+
content: object;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
rates: Record<string, 1 | -1>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface LocalData {
|
|
22
|
+
comments: LocalComment[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dataFile = env('COMMENTS_DATA') || 'data/comments.json';
|
|
26
|
+
const DEV_ROLE = { name: 'dev', canDelete: true };
|
|
27
|
+
|
|
28
|
+
let state: LocalData | undefined;
|
|
29
|
+
|
|
30
|
+
function data(): LocalData {
|
|
31
|
+
if (!state) {
|
|
32
|
+
state = { comments: [] };
|
|
33
|
+
try {
|
|
34
|
+
if (existsSync(/* turbopackIgnore: true */ dataFile))
|
|
35
|
+
state = JSON.parse(
|
|
36
|
+
readFileSync(/* turbopackIgnore: true */ dataFile, 'utf8'),
|
|
37
|
+
) as LocalData;
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
return state;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function persist() {
|
|
44
|
+
mkdirSync(/* turbopackIgnore: true */ dirname(dataFile), { recursive: true });
|
|
45
|
+
writeFileSync(
|
|
46
|
+
/* turbopackIgnore: true */ dataFile,
|
|
47
|
+
`${JSON.stringify(state, null, 2)}\n`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function find(id: string): LocalComment | undefined {
|
|
52
|
+
return data().comments.find((c) => c.id === id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toView(
|
|
56
|
+
c: LocalComment,
|
|
57
|
+
authId: string | undefined,
|
|
58
|
+
all: LocalComment[],
|
|
59
|
+
): Comment {
|
|
60
|
+
let likes = 0;
|
|
61
|
+
let dislikes = 0;
|
|
62
|
+
for (const rate of Object.values(c.rates))
|
|
63
|
+
rate === 1 ? likes++ : dislikes++;
|
|
64
|
+
const rated = authId ? c.rates[authId] : undefined;
|
|
65
|
+
return {
|
|
66
|
+
id: c.id,
|
|
67
|
+
threadId: c.threadId,
|
|
68
|
+
page: c.page,
|
|
69
|
+
author: c.author,
|
|
70
|
+
content: c.content,
|
|
71
|
+
likes,
|
|
72
|
+
dislikes,
|
|
73
|
+
replies: all.filter((r) => r.threadId === c.id).length,
|
|
74
|
+
timestamp: new Date(c.timestamp),
|
|
75
|
+
liked: rated === undefined ? undefined : rated === 1,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createLocalComments(): StorageAdapter {
|
|
80
|
+
return {
|
|
81
|
+
getComments({ after, before, limit, sort, page, auth, thread }) {
|
|
82
|
+
const all = data().comments;
|
|
83
|
+
const authId = auth?.id;
|
|
84
|
+
return all
|
|
85
|
+
.filter(
|
|
86
|
+
(c) =>
|
|
87
|
+
(page === undefined || c.page === page) &&
|
|
88
|
+
(thread === undefined ? !c.threadId : c.threadId === thread) &&
|
|
89
|
+
(!before || new Date(c.timestamp) < before) &&
|
|
90
|
+
(!after || new Date(c.timestamp) > after),
|
|
91
|
+
)
|
|
92
|
+
.sort((a, b) =>
|
|
93
|
+
sort === 'oldest'
|
|
94
|
+
? a.timestamp.localeCompare(b.timestamp)
|
|
95
|
+
: b.timestamp.localeCompare(a.timestamp),
|
|
96
|
+
)
|
|
97
|
+
.slice(0, limit)
|
|
98
|
+
.map((c) => toView(c, authId, all));
|
|
99
|
+
},
|
|
100
|
+
postComment({ auth, body, page }) {
|
|
101
|
+
const comment: LocalComment = {
|
|
102
|
+
id: `c_${crypto.randomUUID()}`,
|
|
103
|
+
page,
|
|
104
|
+
threadId: body.thread,
|
|
105
|
+
author: { id: auth.id, name: auth.id },
|
|
106
|
+
content: body.content,
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
rates: {},
|
|
109
|
+
};
|
|
110
|
+
data().comments.push(comment);
|
|
111
|
+
persist();
|
|
112
|
+
return toView(comment, auth.id, data().comments);
|
|
113
|
+
},
|
|
114
|
+
updateComment({ id, body }) {
|
|
115
|
+
const comment = find(id);
|
|
116
|
+
if (!comment) return;
|
|
117
|
+
comment.content = body.content;
|
|
118
|
+
persist();
|
|
119
|
+
},
|
|
120
|
+
deleteComment({ id }) {
|
|
121
|
+
const d = data();
|
|
122
|
+
const dead = new Set([id]);
|
|
123
|
+
let grew = true;
|
|
124
|
+
while (grew) {
|
|
125
|
+
grew = false;
|
|
126
|
+
for (const c of d.comments)
|
|
127
|
+
if (c.threadId && dead.has(c.threadId) && !dead.has(c.id)) {
|
|
128
|
+
dead.add(c.id);
|
|
129
|
+
grew = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
d.comments = d.comments.filter((c) => !dead.has(c.id));
|
|
133
|
+
persist();
|
|
134
|
+
},
|
|
135
|
+
setRate({ id, auth, body }) {
|
|
136
|
+
const comment = find(id);
|
|
137
|
+
if (!comment) return;
|
|
138
|
+
comment.rates[auth.id] = body.like ? 1 : -1;
|
|
139
|
+
persist();
|
|
140
|
+
},
|
|
141
|
+
deleteRate({ id, auth }) {
|
|
142
|
+
const comment = find(id);
|
|
143
|
+
if (!comment) return;
|
|
144
|
+
delete comment.rates[auth.id];
|
|
145
|
+
persist();
|
|
146
|
+
},
|
|
147
|
+
getCommentAuthor({ id }) {
|
|
148
|
+
return find(id)?.author.id ?? null;
|
|
149
|
+
},
|
|
150
|
+
getRole({ auth }) {
|
|
151
|
+
return auth ? DEV_ROLE : null;
|
|
152
|
+
},
|
|
153
|
+
queryUsers({ name, limit }) {
|
|
154
|
+
const query = name.toLowerCase();
|
|
155
|
+
const users = new Map<string, UserProfile>();
|
|
156
|
+
for (const c of data().comments)
|
|
157
|
+
if (!users.has(c.author.id) && c.author.name.toLowerCase().includes(query))
|
|
158
|
+
users.set(c.author.id, c.author);
|
|
159
|
+
return [...users.values()].slice(0, limit);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let local: StorageAdapter | undefined;
|
|
165
|
+
export function localComments(): StorageAdapter {
|
|
166
|
+
return (local ??= createLocalComments());
|
|
167
|
+
}
|
|
168
|
+
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { NextComment } from '@fuma-comment/server/next';
|
|
3
|
+
type RouteRequest = Request & { nextUrl: { pathname: string } };
|
|
4
|
+
import {
|
|
5
|
+
authConfig,
|
|
6
|
+
authCookie,
|
|
7
|
+
authEnabled,
|
|
8
|
+
authMode,
|
|
9
|
+
devAuthCookie,
|
|
10
|
+
devCookieAuth,
|
|
11
|
+
} from '@terrafirmagreg-team/auth';
|
|
12
|
+
import { env, githubAppToken } from '@terrafirmagreg-team/data';
|
|
13
|
+
import {
|
|
14
|
+
createAuth,
|
|
15
|
+
createGitHubClient,
|
|
16
|
+
createStorage,
|
|
17
|
+
type GitHubDiscussionsConfig,
|
|
18
|
+
} from 'fuma-comment-github-discussions';
|
|
19
|
+
import { discussionsConfig } from '../config';
|
|
20
|
+
import { createDiscussion } from './github';
|
|
21
|
+
import { localComments } from './local';
|
|
22
|
+
|
|
23
|
+
const APP_TOKEN = 'app';
|
|
24
|
+
const CATEGORY = 'General';
|
|
25
|
+
|
|
26
|
+
type DiscussionTarget = {
|
|
27
|
+
dataset: string;
|
|
28
|
+
repo: string;
|
|
29
|
+
repoId: string;
|
|
30
|
+
categoryId: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function datasets(): DiscussionTarget[] {
|
|
34
|
+
return Object.entries(discussionsConfig().datasets).flatMap(
|
|
35
|
+
([dataset, cfg]) =>
|
|
36
|
+
cfg.repo && cfg.discussions
|
|
37
|
+
? [
|
|
38
|
+
{
|
|
39
|
+
dataset,
|
|
40
|
+
repo: cfg.repo,
|
|
41
|
+
repoId: cfg.discussions.repoId,
|
|
42
|
+
categoryId: cfg.discussions.categoryId,
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
: [],
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function datasetSegs(): Set<string> {
|
|
50
|
+
return new Set(datasets().map(({ dataset }) => dataset));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultDataset(): DiscussionTarget {
|
|
54
|
+
return datasets().find(({ dataset }) => dataset === 'dev')!;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function datasetOf(page: string) {
|
|
58
|
+
const segs = datasetSegs();
|
|
59
|
+
const seg = page.split('~').find((part) => segs.has(part));
|
|
60
|
+
return datasets().find(({ dataset }) => dataset === seg) ?? defaultDataset();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pageToTitle(page: string): string {
|
|
64
|
+
const locales = new Set(discussionsConfig().knownLanguages);
|
|
65
|
+
const segs = datasetSegs();
|
|
66
|
+
const pretty = page
|
|
67
|
+
.split('~')
|
|
68
|
+
.slice(0, -1)
|
|
69
|
+
.filter((seg) => seg && !locales.has(seg) && !segs.has(seg))
|
|
70
|
+
.map((seg) =>
|
|
71
|
+
seg.replace(/[-_]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase()),
|
|
72
|
+
);
|
|
73
|
+
return pretty.join(' › ') || 'Page';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Client = ReturnType<typeof createGitHubClient>;
|
|
77
|
+
type Storage = ReturnType<typeof createStorage>;
|
|
78
|
+
|
|
79
|
+
let siteUrl: string | undefined;
|
|
80
|
+
|
|
81
|
+
function currentSiteUrl(): string {
|
|
82
|
+
return siteUrl ?? discussionsConfig().siteUrl;
|
|
83
|
+
}
|
|
84
|
+
const storages = new Map<string, Storage>();
|
|
85
|
+
|
|
86
|
+
function cfgFor(d: DiscussionTarget): GitHubDiscussionsConfig {
|
|
87
|
+
return {
|
|
88
|
+
repo: d.repo,
|
|
89
|
+
repoId: d.repoId,
|
|
90
|
+
categoryId: d.categoryId,
|
|
91
|
+
category: CATEGORY,
|
|
92
|
+
readToken: APP_TOKEN,
|
|
93
|
+
tokenSecret: authConfig().tokenSecret,
|
|
94
|
+
cookieName: authCookie,
|
|
95
|
+
pageToTitle,
|
|
96
|
+
pageToUrl: (page) => {
|
|
97
|
+
const segs = page.split('~');
|
|
98
|
+
segs.pop();
|
|
99
|
+
return `${currentSiteUrl()}/${segs.filter(Boolean).join('/')}`;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function wrapClient(d: DiscussionTarget, cfg: GitHubDiscussionsConfig): Client {
|
|
105
|
+
const base = createGitHubClient(cfg);
|
|
106
|
+
const wrapped = { ...base };
|
|
107
|
+
for (const key of Object.keys(base) as (keyof Client)[]) {
|
|
108
|
+
const fn = base[key] as (...args: unknown[]) => unknown;
|
|
109
|
+
(wrapped as Record<string, unknown>)[key] = async (...args: unknown[]) => {
|
|
110
|
+
if (args.includes(APP_TOKEN)) {
|
|
111
|
+
const token = await githubAppToken(d.repo);
|
|
112
|
+
args = args.map((arg) => (arg === APP_TOKEN ? token : arg));
|
|
113
|
+
}
|
|
114
|
+
return fn(...args);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
wrapped.createDiscussion = async (title, url, token) => {
|
|
118
|
+
const resolved = token === APP_TOKEN ? await githubAppToken(d.repo) : token;
|
|
119
|
+
return createDiscussion(cfg, title, url, resolved);
|
|
120
|
+
};
|
|
121
|
+
return wrapped;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function storageFor(d: DiscussionTarget): Storage {
|
|
125
|
+
let storage = storages.get(d.dataset);
|
|
126
|
+
if (!storage) {
|
|
127
|
+
const cfg = cfgFor(d);
|
|
128
|
+
storage = createStorage(cfg, wrapClient(d, cfg));
|
|
129
|
+
storages.set(d.dataset, storage);
|
|
130
|
+
}
|
|
131
|
+
return storage;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type RouteHandler = (
|
|
135
|
+
req: RouteRequest,
|
|
136
|
+
context: { params: Promise<{ comment: string[] }> },
|
|
137
|
+
) => Promise<Response>;
|
|
138
|
+
|
|
139
|
+
export function commentsEnabled(): boolean {
|
|
140
|
+
return authEnabled() && datasets().length > 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function feedbackEnabled(): boolean {
|
|
144
|
+
return commentsEnabled() && Boolean(env('APP_ID') && env('APP_PRIVATE_KEY'));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createCommentsRouter(
|
|
148
|
+
options: { siteUrl?: string } = {},
|
|
149
|
+
):
|
|
150
|
+
| { GET: RouteHandler; POST: RouteHandler; PATCH: RouteHandler; DELETE: RouteHandler }
|
|
151
|
+
| undefined {
|
|
152
|
+
if (!commentsEnabled()) return undefined;
|
|
153
|
+
siteUrl = options.siteUrl ?? discussionsConfig().siteUrl;
|
|
154
|
+
|
|
155
|
+
if (authMode() === 'dev' || !(env('APP_ID') && env('APP_PRIVATE_KEY')))
|
|
156
|
+
return NextComment({
|
|
157
|
+
role: 'database',
|
|
158
|
+
mention: { enabled: true },
|
|
159
|
+
storage: localComments(),
|
|
160
|
+
auth: devCookieAuth({ cookieName: devAuthCookie }),
|
|
161
|
+
}) as unknown as { GET: RouteHandler; POST: RouteHandler; PATCH: RouteHandler; DELETE: RouteHandler };
|
|
162
|
+
|
|
163
|
+
const dispatch = (page: string | undefined) =>
|
|
164
|
+
storageFor(datasetOf(page ?? ''));
|
|
165
|
+
|
|
166
|
+
const storage = {
|
|
167
|
+
getComments: (args: Parameters<Storage['getComments']>[0]) =>
|
|
168
|
+
dispatch(args.page as string | undefined).getComments(args),
|
|
169
|
+
postComment: (args: Parameters<Storage['postComment']>[0]) =>
|
|
170
|
+
dispatch(args.page as string | undefined).postComment(args),
|
|
171
|
+
updateComment: (args: Parameters<Storage['updateComment']>[0]) =>
|
|
172
|
+
dispatch(args.page).updateComment(args),
|
|
173
|
+
deleteComment: (args: Parameters<Storage['deleteComment']>[0]) =>
|
|
174
|
+
dispatch(args.page).deleteComment(args),
|
|
175
|
+
setRate: (args: Parameters<Storage['setRate']>[0]) =>
|
|
176
|
+
dispatch(args.page).setRate(args),
|
|
177
|
+
deleteRate: (args: Parameters<Storage['deleteRate']>[0]) =>
|
|
178
|
+
dispatch(args.page).deleteRate(args),
|
|
179
|
+
getCommentAuthor: async (
|
|
180
|
+
args: Parameters<Storage['getCommentAuthor']>[0],
|
|
181
|
+
) => {
|
|
182
|
+
for (const d of datasets()) {
|
|
183
|
+
const author = await Promise.resolve(
|
|
184
|
+
storageFor(d).getCommentAuthor(args),
|
|
185
|
+
).catch(() => null);
|
|
186
|
+
if (author) return author;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
},
|
|
190
|
+
getRole: (args: Parameters<Storage['getRole']>[0]) =>
|
|
191
|
+
storageFor(datasetOf(args.page)).getRole(args),
|
|
192
|
+
queryUsers: (args: Parameters<NonNullable<Storage['queryUsers']>>[0]) =>
|
|
193
|
+
dispatch(args.page).queryUsers?.(args) ?? [],
|
|
194
|
+
} as Storage;
|
|
195
|
+
|
|
196
|
+
const cfg = cfgFor(defaultDataset());
|
|
197
|
+
return NextComment({
|
|
198
|
+
role: 'database',
|
|
199
|
+
mention: { enabled: true },
|
|
200
|
+
storage,
|
|
201
|
+
auth: createAuth(cfg, wrapClient(defaultDataset(), cfg)),
|
|
202
|
+
}) as unknown as { GET: RouteHandler; POST: RouteHandler; PATCH: RouteHandler; DELETE: RouteHandler };
|
|
203
|
+
}
|
|
204
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface CommentsViewProps {
|
|
4
|
+
page?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type CommentsView = ComponentType<CommentsViewProps>;
|
|
8
|
+
|
|
9
|
+
let view: CommentsView | undefined;
|
|
10
|
+
|
|
11
|
+
export function registerCommentsView(component: CommentsView): void {
|
|
12
|
+
view = component;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function commentsView(): CommentsView | undefined {
|
|
16
|
+
return view;
|
|
17
|
+
}
|
|
18
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type DiscussionsDataset = {
|
|
2
|
+
repo?: string;
|
|
3
|
+
discussions?: { repoId: string; categoryId: string };
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type DiscussionsConfig = {
|
|
7
|
+
datasets: Record<string, DiscussionsDataset>;
|
|
8
|
+
siteUrl: string;
|
|
9
|
+
knownLanguages: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let config: DiscussionsConfig | undefined;
|
|
13
|
+
|
|
14
|
+
export function registerDiscussionsConfig(
|
|
15
|
+
input: Partial<DiscussionsConfig>,
|
|
16
|
+
): void {
|
|
17
|
+
config = { ...config, ...input } as DiscussionsConfig;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function discussionsConfig(): DiscussionsConfig {
|
|
21
|
+
if (!config) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'Discussions config is not registered: call registerDiscussionsConfig() at app startup.',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return config;
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod/mini';
|
|
2
|
+
|
|
3
|
+
export const blockFeedback = z.object({
|
|
4
|
+
url: z.url(),
|
|
5
|
+
blockId: z.string(),
|
|
6
|
+
message: z.string(),
|
|
7
|
+
|
|
8
|
+
blockBody: z.string(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const pageFeedback = z.object({
|
|
12
|
+
opinion: z.enum(['good', 'bad']),
|
|
13
|
+
url: z.url(),
|
|
14
|
+
message: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const actionResponse = z.object({
|
|
18
|
+
githubUrl: z.optional(z.string()),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type BlockFeedback = z.infer<typeof blockFeedback>;
|
|
22
|
+
export type PageFeedback = z.infer<typeof pageFeedback>;
|
|
23
|
+
export type ActionResponse = z.infer<typeof actionResponse>;
|
|
24
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
import { githubAppToken } from '@terrafirmagreg-team/data';
|
|
3
|
+
import { createGitHubClient } from 'fuma-comment-github-discussions';
|
|
4
|
+
import { discussionsConfig } from './config';
|
|
5
|
+
import { createDiscussion } from './comments/github';
|
|
6
|
+
import {
|
|
7
|
+
blockFeedback,
|
|
8
|
+
pageFeedback,
|
|
9
|
+
type ActionResponse,
|
|
10
|
+
type BlockFeedback,
|
|
11
|
+
type PageFeedback,
|
|
12
|
+
} from './feedback-schema';
|
|
13
|
+
|
|
14
|
+
function targetOf(pathname: string) {
|
|
15
|
+
const datasets = discussionsConfig().datasets;
|
|
16
|
+
const seg = pathname.split('/').find((part) => part in datasets);
|
|
17
|
+
const cfg = (seg && datasets[seg]) || datasets.dev;
|
|
18
|
+
if (!cfg?.repo || !cfg.discussions) return undefined;
|
|
19
|
+
return {
|
|
20
|
+
repo: cfg.repo,
|
|
21
|
+
repoId: cfg.discussions.repoId,
|
|
22
|
+
categoryId: cfg.discussions.categoryId,
|
|
23
|
+
category: 'General',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function onPageFeedbackAction(
|
|
28
|
+
feedback: PageFeedback,
|
|
29
|
+
): Promise<ActionResponse> {
|
|
30
|
+
feedback = pageFeedback.parse(feedback);
|
|
31
|
+
const { pathname } = new URL(feedback.url);
|
|
32
|
+
|
|
33
|
+
return createDiscussionThread(
|
|
34
|
+
pathname,
|
|
35
|
+
`[${feedback.opinion}] ${feedback.message}\n\n> Forwarded from user feedback.`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function onBlockFeedbackAction(
|
|
40
|
+
feedback: BlockFeedback,
|
|
41
|
+
): Promise<ActionResponse> {
|
|
42
|
+
feedback = blockFeedback.parse(feedback);
|
|
43
|
+
const url = new URL(feedback.url);
|
|
44
|
+
url.hash = feedback.blockId;
|
|
45
|
+
|
|
46
|
+
return createDiscussionThread(
|
|
47
|
+
url.pathname,
|
|
48
|
+
`> ${feedback.blockBody}\n\n${feedback.message}\n\n> [Forwarded from user feedback](${url.href}).`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function createDiscussionThread(
|
|
53
|
+
pageId: string,
|
|
54
|
+
body: string,
|
|
55
|
+
): Promise<ActionResponse> {
|
|
56
|
+
const target = targetOf(pageId);
|
|
57
|
+
if (!target) return { githubUrl: '' };
|
|
58
|
+
const github = createGitHubClient(target);
|
|
59
|
+
const auth = await githubAppToken(target.repo);
|
|
60
|
+
const title = `Feedback for ${pageId}`;
|
|
61
|
+
|
|
62
|
+
const discussion =
|
|
63
|
+
(await github.findDiscussion(title, auth)) ??
|
|
64
|
+
(await createDiscussion(
|
|
65
|
+
target,
|
|
66
|
+
title,
|
|
67
|
+
`${discussionsConfig().siteUrl}${pageId}`,
|
|
68
|
+
auth,
|
|
69
|
+
));
|
|
70
|
+
await github.addComment(discussion.id, body, auth);
|
|
71
|
+
|
|
72
|
+
return { githubUrl: discussion.url };
|
|
73
|
+
}
|
|
74
|
+
|