@meith/web 0.31.0 → 0.32.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/app/(board)/[slug]/feed.xml/route.ts +5 -3
- package/app/(board)/[slug]/new/page.tsx +5 -0
- package/app/(board)/[slug]/page.tsx +7 -1
- package/app/(board)/discover/[view]/page.tsx +6 -1
- package/app/(board)/member/[id]/page.tsx +3 -5
- package/app/(board)/member/[id]/reputation/page.tsx +8 -15
- package/app/(board)/members/page.tsx +3 -8
- package/app/(board)/messages/[id]/page.tsx +3 -7
- package/app/(board)/messages/page.tsx +3 -5
- package/app/(board)/modcp/ip/page.tsx +3 -5
- package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
- package/app/(board)/moderation/reports/page.tsx +53 -15
- package/app/(board)/moderation/warn/page.tsx +3 -6
- package/app/(board)/notifications/page.tsx +5 -10
- package/app/(board)/notifications/preferences/page.tsx +8 -0
- package/app/(board)/online/page.tsx +12 -6
- package/app/(board)/staff/page.tsx +4 -5
- package/app/(board)/stats/page.tsx +10 -3
- package/app/(board)/subscriptions/page.tsx +5 -10
- package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
- package/app/(board)/thread/[slug]/page.tsx +49 -11
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
- package/app/(board)/unsubscribe/page.tsx +8 -11
- package/app/(board)/usercp/contacts/page.tsx +3 -5
- package/app/(board)/usercp/drafts/page.tsx +70 -0
- package/app/(board)/usercp/options/page.tsx +8 -1
- package/app/(board)/usercp/security/page.tsx +16 -0
- package/app/admin/antispam/page.tsx +4 -12
- package/app/admin/content/attachments/page.tsx +3 -6
- package/app/admin/content/page.tsx +3 -13
- package/app/admin/forums/[id]/permissions/page.tsx +9 -12
- package/app/admin/groups/page.tsx +7 -9
- package/app/admin/page.tsx +12 -6
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
- package/app/admin/plugins/page.tsx +3 -6
- package/app/admin/users/[id]/page.tsx +5 -11
- package/app/admin/users/prune/page.tsx +4 -12
- package/app/admin/webhooks/page.tsx +144 -0
- package/app/api/subscribe/thread/[id]/route.ts +53 -0
- package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
- package/app/atom.xml/route.ts +2 -2
- package/app/auth/sso/[provider]/callback/route.ts +10 -0
- package/app/feed.xml/route.ts +2 -2
- package/app/offline/route.ts +98 -0
- package/next.config.mjs +1 -0
- package/package.json +49 -48
- package/public/sw.js +42 -2
- package/src/components/account/draft-forms.tsx +39 -0
- package/src/components/account/feed-token.tsx +95 -0
- package/src/components/account/notification-forms.tsx +37 -2
- package/src/components/account/relation-forms.tsx +3 -2
- package/src/components/account/subscription-forms.tsx +20 -6
- package/src/components/account/usercp-forms.tsx +44 -0
- package/src/components/admin/api-token-forms.tsx +11 -7
- package/src/components/admin/ban-filter-forms.tsx +15 -11
- package/src/components/admin/content-forms.tsx +31 -14
- package/src/components/admin/forum-forms.tsx +241 -129
- package/src/components/admin/forum-tree.tsx +5 -7
- package/src/components/admin/navigation-forms.tsx +43 -26
- package/src/components/admin/navigation-tree.tsx +8 -13
- package/src/components/admin/oklch-picker.tsx +2 -1
- package/src/components/admin/theme-changes.tsx +3 -2
- package/src/components/admin/theme-forms.tsx +3 -2
- package/src/components/admin/webhook-forms.tsx +137 -0
- package/src/components/auth/register-form.tsx +4 -7
- package/src/components/board/live-region.tsx +3 -1
- package/src/components/content/attachment-field.tsx +93 -22
- package/src/components/content/combobox-keys.ts +38 -0
- package/src/components/content/edit-post-form.tsx +75 -1
- package/src/components/content/markdown-editor.tsx +29 -86
- package/src/components/content/mention-combobox.tsx +137 -0
- package/src/components/content/multiquote-button.tsx +15 -12
- package/src/components/content/new-thread-form.tsx +95 -17
- package/src/components/content/poll-edit-form.tsx +61 -39
- package/src/components/content/poll-vote-form.tsx +91 -0
- package/src/components/content/poll.tsx +12 -48
- package/src/components/content/progressive-marker.tsx +13 -0
- package/src/components/content/recipient-segment.ts +39 -0
- package/src/components/content/reply-form.tsx +9 -1
- package/src/components/content/thanks-button.tsx +12 -7
- package/src/components/messages/message-forms.tsx +53 -43
- package/src/components/messages/recipient-field.tsx +88 -0
- package/src/components/moderation/inline-moderation-form.tsx +2 -0
- package/src/components/moderation/modcp-shell.tsx +17 -3
- package/src/components/moderation/queue-form.tsx +3 -6
- package/src/components/moderation/report-forms.tsx +24 -5
- package/src/components/moderation/thread-tools-form.tsx +2 -0
- package/src/components/shell/board-notice.tsx +22 -1
- package/src/components/shell/confirm-dialog.tsx +93 -0
- package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
- package/src/components/shell/notice-toast.tsx +40 -0
- package/src/components/shell/notification-menu.tsx +5 -3
- package/src/components/shell/page-shell.tsx +3 -0
- package/src/components/shell/scheme-toggle.tsx +72 -0
- package/src/components/shell/theme-switcher.tsx +16 -36
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/api/search.ts +8 -2
- package/src/server/api-token-actions.ts +4 -0
- package/src/server/appearance-actions.ts +21 -0
- package/src/server/attachments.ts +35 -0
- package/src/server/auth-actions.ts +2 -0
- package/src/server/auth-form-state.ts +28 -0
- package/src/server/auto-watch.ts +31 -0
- package/src/server/ban-filter-admin-actions.ts +5 -0
- package/src/server/confirm.ts +19 -0
- package/src/server/content-actions.ts +129 -19
- package/src/server/content-admin-actions.ts +31 -0
- package/src/server/discovery.ts +11 -1
- package/src/server/feed-routes.ts +13 -4
- package/src/server/feed-token-actions.ts +61 -0
- package/src/server/feed-token.ts +78 -0
- package/src/server/fixture-post-repo.ts +18 -0
- package/src/server/forum-admin-actions.ts +23 -16
- package/src/server/forum-admin.ts +10 -1
- package/src/server/inline-moderation-actions.ts +6 -0
- package/src/server/message-actions.ts +9 -0
- package/src/server/navigation-admin-actions.ts +6 -0
- package/src/server/plugin-admin.ts +7 -2
- package/src/server/plugin-pages.ts +50 -0
- package/src/server/plugin-panel.ts +34 -1
- package/src/server/plugin-routes.ts +8 -0
- package/src/server/plugin-view.tsx +46 -7
- package/src/server/poll-actions.ts +13 -1
- package/src/server/presence.ts +23 -0
- package/src/server/reply-core.ts +8 -1
- package/src/server/report-actions.ts +13 -2
- package/src/server/reputation-actions.ts +12 -1
- package/src/server/search-page.ts +8 -2
- package/src/server/search.ts +18 -5
- package/src/server/subscription-actions.ts +9 -0
- package/src/server/thread-core.ts +10 -2
- package/src/server/thread-tool-actions.ts +6 -0
- package/src/server/unread-goto.ts +28 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/usercp-actions.ts +31 -1
- package/src/server/viewer-preferences.ts +5 -0
- package/src/server/webhook-actions.ts +98 -0
- package/src/server/webhooks-admin.ts +122 -0
- package/src/theme/contract.fixture.ts +36 -5
- package/src/view/account-copy.ts +39 -0
- package/src/view/admin-copy.ts +4 -9
- package/src/view/admin-nav.ts +6 -0
- package/src/view/admin-panel-copy.ts +23 -0
- package/src/view/attachments.ts +33 -1
- package/src/view/board-index.ts +31 -2
- package/src/view/board-title.ts +1 -0
- package/src/view/content-copy.ts +22 -12
- package/src/view/copy.ts +17 -0
- package/src/view/discovery-view.ts +2 -1
- package/src/view/drafts.ts +45 -0
- package/src/view/editor-toolbar.ts +14 -0
- package/src/view/forum-display.ts +39 -7
- package/src/view/modcp-nav.ts +32 -0
- package/src/view/moderation-copy.ts +20 -0
- package/src/view/notifications.ts +1 -0
- package/src/view/plugin-panel.ts +13 -0
- package/src/view/poll-vote.ts +46 -0
- package/src/view/post-link.ts +8 -1
- package/src/view/progressive-enhancement.ts +5 -0
- package/src/view/shell.ts +2 -1
- package/src/view/subscriptions.ts +1 -0
- package/src/view/thread-view.ts +41 -2
- package/src/view/usercp-nav.ts +6 -0
- package/src/view/usercp.ts +14 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Metadata } from 'next'
|
|
2
2
|
|
|
3
|
+
import { TextLink } from '@meith/ui'
|
|
4
|
+
|
|
3
5
|
import { CreateGroupForm } from '@/components/admin/group-forms'
|
|
4
6
|
import { PANEL_CARD, PANEL_LIST, PANEL_ROW, PanelActionLink } from '@/components/shell/panel-list'
|
|
5
7
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
@@ -36,12 +38,7 @@ export default async function AdminGroupsPage() {
|
|
|
36
38
|
<>
|
|
37
39
|
{translator.t('adminGroups.ledeBefore')} <em>{translator.t('adminGroups.default')}</em>
|
|
38
40
|
{translator.t('adminGroups.ledeBetween')}{' '}
|
|
39
|
-
<
|
|
40
|
-
href="/admin/forums"
|
|
41
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
42
|
-
>
|
|
43
|
-
{translator.t('adminGroups.forumPermissions')}
|
|
44
|
-
</a>{' '}
|
|
41
|
+
<TextLink href="/admin/forums">{translator.t('adminGroups.forumPermissions')}</TextLink>{' '}
|
|
45
42
|
{translator.t('adminGroups.ledeEnd')}
|
|
46
43
|
</>
|
|
47
44
|
}
|
|
@@ -68,13 +65,14 @@ export default async function AdminGroupsPage() {
|
|
|
68
65
|
{group.description === null ? '' : ` · ${group.description}`}
|
|
69
66
|
</span>
|
|
70
67
|
</span>
|
|
71
|
-
<
|
|
68
|
+
<TextLink
|
|
72
69
|
href={`/admin/groups/${group.id}`}
|
|
73
70
|
aria-label={translator.t('adminGroups.editLabel', { title: group.title })}
|
|
74
|
-
|
|
71
|
+
size="sm"
|
|
72
|
+
className="shrink-0"
|
|
75
73
|
>
|
|
76
74
|
{translator.t('adminGroups.edit')}
|
|
77
|
-
</
|
|
75
|
+
</TextLink>
|
|
78
76
|
</li>
|
|
79
77
|
))}
|
|
80
78
|
</ul>
|
package/app/admin/page.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ModerationQueue } from '@meith/moderation'
|
|
2
|
+
import { PLUGIN_CARD } from '@meith/plugin-kit'
|
|
2
3
|
import {
|
|
3
4
|
Alert,
|
|
4
5
|
AlertDescription,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
Empty,
|
|
13
14
|
EmptyDescription,
|
|
14
15
|
EmptyTitle,
|
|
16
|
+
TextLink,
|
|
15
17
|
} from '@meith/ui'
|
|
16
18
|
|
|
17
19
|
import { PanelSectionGrid, PanelWaitingList } from '@/components/shell/panel-overview'
|
|
@@ -20,6 +22,7 @@ import { adminPageContext } from '@/server/admin'
|
|
|
20
22
|
import { getContainer } from '@/server/container'
|
|
21
23
|
import { getActor } from '@/server/context'
|
|
22
24
|
import { getTranslator, tr } from '@/server/i18n'
|
|
25
|
+
import { boardRegion } from '@/server/plugin-view'
|
|
23
26
|
import { hasReportScope, resolveReportScope } from '@/server/report-scope'
|
|
24
27
|
import { readTotals } from '@/server/stats'
|
|
25
28
|
import { pendingUpgradeNotice } from '@/server/upgrade-notice'
|
|
@@ -57,6 +60,8 @@ export default async function AdminHomePage() {
|
|
|
57
60
|
|
|
58
61
|
const allClear = pending === 0 && openReports === 0 && upgradeNotice === null
|
|
59
62
|
|
|
63
|
+
const dashboard = await boardRegion('admin.dashboard', actor, PLUGIN_CARD)
|
|
64
|
+
|
|
60
65
|
return (
|
|
61
66
|
<PanelPage
|
|
62
67
|
title={await tr('page.overview')}
|
|
@@ -157,6 +162,12 @@ export default async function AdminHomePage() {
|
|
|
157
162
|
</PanelSection>
|
|
158
163
|
)}
|
|
159
164
|
|
|
165
|
+
{dashboard !== null && (
|
|
166
|
+
<PanelSection id="plugins-heading" title={await tr('page.plugins')}>
|
|
167
|
+
<div className="flex flex-col gap-4">{dashboard}</div>
|
|
168
|
+
</PanelSection>
|
|
169
|
+
)}
|
|
170
|
+
|
|
160
171
|
<PanelSection id="activity-heading" title={await tr('page.latest-activity')}>
|
|
161
172
|
<Card>
|
|
162
173
|
{recent.length === 0 ? (
|
|
@@ -186,12 +197,7 @@ export default async function AdminHomePage() {
|
|
|
186
197
|
))}
|
|
187
198
|
</CardRows>
|
|
188
199
|
<CardFooter>
|
|
189
|
-
<
|
|
190
|
-
href="/admin/log"
|
|
191
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
192
|
-
>
|
|
193
|
-
{await tr('page.whole-log')}
|
|
194
|
-
</a>
|
|
200
|
+
<TextLink href="/admin/log">{await tr('page.whole-log')}</TextLink>
|
|
195
201
|
</CardFooter>
|
|
196
202
|
</>
|
|
197
203
|
)}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Metadata } from 'next'
|
|
2
2
|
import { notFound } from 'next/navigation'
|
|
3
3
|
|
|
4
|
+
import { TextLink } from '@meith/ui'
|
|
5
|
+
|
|
4
6
|
import { PluginEnableForm, PluginSettingsForm } from '@/components/admin/plugin-forms'
|
|
5
7
|
import { PANEL_CARD, PANEL_NOTE } from '@/components/shell/panel-list'
|
|
6
8
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
@@ -219,12 +221,7 @@ export default async function AdminPluginPage({
|
|
|
219
221
|
<h2 className="font-heading text-lg font-semibold">{await tr('page.scheduled-tasks')}</h2>
|
|
220
222
|
<p className="text-sm text-muted-foreground">
|
|
221
223
|
{t.t('adminPluginDetail.tasksHintBefore')}{' '}
|
|
222
|
-
<
|
|
223
|
-
href="/admin/system"
|
|
224
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
225
|
-
>
|
|
226
|
-
{t.t('page.system-health')}
|
|
227
|
-
</a>{' '}
|
|
224
|
+
<TextLink href="/admin/system">{t.t('page.system-health')}</TextLink>{' '}
|
|
228
225
|
{t.t('adminPluginDetail.tasksHintAfter')}
|
|
229
226
|
</p>
|
|
230
227
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
|
@@ -232,7 +229,9 @@ export default async function AdminPluginPage({
|
|
|
232
229
|
<li key={task.id} className="flex justify-between gap-3 py-2">
|
|
233
230
|
<code className="text-xs">{task.registeredId}</code>
|
|
234
231
|
<span className="text-xs text-muted-foreground">
|
|
235
|
-
{
|
|
232
|
+
{task.schedule === null
|
|
233
|
+
? t.t('adminPluginDetail.every', { seconds: task.intervalSeconds })
|
|
234
|
+
: t.t('adminPluginDetail.schedule', { schedule: task.schedule })}
|
|
236
235
|
</span>
|
|
237
236
|
</li>
|
|
238
237
|
))}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Metadata } from 'next'
|
|
2
2
|
|
|
3
|
-
import { cn } from '@meith/ui'
|
|
3
|
+
import { cn, TextLink } from '@meith/ui'
|
|
4
4
|
|
|
5
5
|
import { MarketplaceRefreshForm } from '@/components/admin/marketplace-forms'
|
|
6
6
|
import { PluginEnableForm, PluginHealthResetForm } from '@/components/admin/plugin-forms'
|
|
@@ -128,12 +128,9 @@ export default async function AdminPluginsPage() {
|
|
|
128
128
|
</span>
|
|
129
129
|
|
|
130
130
|
<span className="flex shrink-0 items-center gap-3">
|
|
131
|
-
<
|
|
132
|
-
href={`/admin/plugins/${plugin.key}`}
|
|
133
|
-
className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
134
|
-
>
|
|
131
|
+
<TextLink href={`/admin/plugins/${plugin.key}`} size="sm">
|
|
135
132
|
{t.t('adminPlugins.details')}
|
|
136
|
-
</
|
|
133
|
+
</TextLink>
|
|
137
134
|
{plugin.durableHealth?.disabledAt != null && (
|
|
138
135
|
<PluginHealthResetForm pluginKey={plugin.key} copy={copy} />
|
|
139
136
|
)}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Metadata } from 'next'
|
|
2
2
|
import { notFound } from 'next/navigation'
|
|
3
3
|
|
|
4
|
+
import { TextLink } from '@meith/ui'
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
BanMemberForm,
|
|
6
8
|
ClearSecondFactorForm,
|
|
@@ -151,12 +153,9 @@ export default async function AdminMemberPage({ params }: { params: Promise<{ id
|
|
|
151
153
|
<h2 className="font-heading text-lg font-semibold">{translator.t('adminUsers.merge')}</h2>
|
|
152
154
|
<p className="text-sm text-muted-foreground">{translator.t('adminUsers.mergeLede')}</p>
|
|
153
155
|
<p>
|
|
154
|
-
<
|
|
155
|
-
href={`/admin/users/${member.id}/merge`}
|
|
156
|
-
className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
157
|
-
>
|
|
156
|
+
<TextLink href={`/admin/users/${member.id}/merge`} size="sm">
|
|
158
157
|
{translator.t('adminUsers.mergeLink', { username: member.username })}
|
|
159
|
-
</
|
|
158
|
+
</TextLink>
|
|
160
159
|
</p>
|
|
161
160
|
</section>
|
|
162
161
|
|
|
@@ -185,12 +184,7 @@ export default async function AdminMemberPage({ params }: { params: Promise<{ id
|
|
|
185
184
|
<ul className="flex flex-col gap-1 text-sm">
|
|
186
185
|
{view.sharedNetwork.map((row) => (
|
|
187
186
|
<li key={row.id}>
|
|
188
|
-
<
|
|
189
|
-
href={`/admin/users/${row.id}`}
|
|
190
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
191
|
-
>
|
|
192
|
-
{row.username}
|
|
193
|
-
</a>{' '}
|
|
187
|
+
<TextLink href={`/admin/users/${row.id}`}>{row.username}</TextLink>{' '}
|
|
194
188
|
<span className="text-xs text-muted-foreground">
|
|
195
189
|
{row.state === 'active'
|
|
196
190
|
? ''
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Metadata } from 'next'
|
|
2
2
|
|
|
3
|
+
import { TextLink } from '@meith/ui'
|
|
4
|
+
|
|
3
5
|
import { PruneForm, SelectedPruneForm } from '@/components/admin/user-forms'
|
|
4
6
|
import { PANEL_CARD, PANEL_NOTE } from '@/components/shell/panel-list'
|
|
5
7
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
@@ -72,12 +74,7 @@ export default async function AdminPrunePage({
|
|
|
72
74
|
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
|
|
73
75
|
{selectedPreview.sample.map((row) => (
|
|
74
76
|
<li key={row.id}>
|
|
75
|
-
<
|
|
76
|
-
href={`/admin/users/${row.id}`}
|
|
77
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
78
|
-
>
|
|
79
|
-
{row.username}
|
|
80
|
-
</a>{' '}
|
|
77
|
+
<TextLink href={`/admin/users/${row.id}`}>{row.username}</TextLink>{' '}
|
|
81
78
|
{translator.t('adminUsers.pruneMemberMeta', {
|
|
82
79
|
email: row.email,
|
|
83
80
|
date: row.createdAt.toISOString().slice(0, 10),
|
|
@@ -150,12 +147,7 @@ export default async function AdminPrunePage({
|
|
|
150
147
|
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
|
|
151
148
|
{preview.sample.map((row) => (
|
|
152
149
|
<li key={row.id}>
|
|
153
|
-
<
|
|
154
|
-
href={`/admin/users/${row.id}`}
|
|
155
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
156
|
-
>
|
|
157
|
-
{row.username}
|
|
158
|
-
</a>{' '}
|
|
150
|
+
<TextLink href={`/admin/users/${row.id}`}>{row.username}</TextLink>{' '}
|
|
159
151
|
{translator.t('adminUsers.pruneMemberMeta', {
|
|
160
152
|
email: row.email,
|
|
161
153
|
date: row.createdAt.toISOString().slice(0, 10),
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
CreateWebhookForm,
|
|
5
|
+
DeleteWebhookForm,
|
|
6
|
+
ToggleWebhookForm,
|
|
7
|
+
} from '@/components/admin/webhook-forms'
|
|
8
|
+
import { PANEL_CARD } from '@/components/shell/panel-list'
|
|
9
|
+
import { PanelPage } from '@/components/shell/panel-page'
|
|
10
|
+
import { adminPageContext } from '@/server/admin'
|
|
11
|
+
import { getTranslator, tr } from '@/server/i18n'
|
|
12
|
+
import { buildWebhookView } from '@/server/webhooks-admin'
|
|
13
|
+
import { webhookFormsCopy } from '@/view/admin-panel-copy'
|
|
14
|
+
import { formatTime } from '@/view/time'
|
|
15
|
+
|
|
16
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
17
|
+
return { title: await tr('page.webhooks') }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function AdminWebhooksPage() {
|
|
21
|
+
if ((await adminPageContext()) === null) return null
|
|
22
|
+
|
|
23
|
+
const now = new Date()
|
|
24
|
+
const translator = await getTranslator()
|
|
25
|
+
const copy = webhookFormsCopy(translator)
|
|
26
|
+
const view = await buildWebhookView()
|
|
27
|
+
|
|
28
|
+
if (view === null) {
|
|
29
|
+
return (
|
|
30
|
+
<PanelPage title={await tr('page.webhooks')}>
|
|
31
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
32
|
+
{await tr('page.this-board-running-in-memory-sample-c')}
|
|
33
|
+
</p>
|
|
34
|
+
</PanelPage>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const statusLabel: Record<string, string> = {
|
|
39
|
+
delivered: translator.t('adminWebhooks.delivered'),
|
|
40
|
+
pending: translator.t('adminWebhooks.pending'),
|
|
41
|
+
dead: translator.t('adminWebhooks.dead'),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<PanelPage
|
|
46
|
+
title={await tr('page.webhooks')}
|
|
47
|
+
lede={translator.t('adminWebhooks.lede')}
|
|
48
|
+
gap="loose"
|
|
49
|
+
>
|
|
50
|
+
<section className={PANEL_CARD}>
|
|
51
|
+
<h2 className="font-heading text-lg font-semibold">{translator.t('adminWebhooks.add')}</h2>
|
|
52
|
+
<CreateWebhookForm topics={view.topics} formats={view.formats} copy={copy} />
|
|
53
|
+
</section>
|
|
54
|
+
|
|
55
|
+
<section className={PANEL_CARD}>
|
|
56
|
+
<h2 className="font-heading text-lg font-semibold">
|
|
57
|
+
{translator.t('adminWebhooks.existing')}
|
|
58
|
+
</h2>
|
|
59
|
+
|
|
60
|
+
{view.subscriptions.length === 0 ? (
|
|
61
|
+
<p className="text-sm text-muted-foreground">{translator.t('adminWebhooks.empty')}</p>
|
|
62
|
+
) : (
|
|
63
|
+
<ul className="flex flex-col gap-4">
|
|
64
|
+
{view.subscriptions.map((subscription) => (
|
|
65
|
+
<li
|
|
66
|
+
key={subscription.id}
|
|
67
|
+
className="flex flex-col gap-3 rounded-md border border-border p-3"
|
|
68
|
+
>
|
|
69
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
70
|
+
<code className="overflow-x-auto font-mono text-xs">{subscription.url}</code>
|
|
71
|
+
<span
|
|
72
|
+
className={
|
|
73
|
+
subscription.active ? 'text-moderation-approved' : 'text-muted-foreground'
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
{subscription.active
|
|
77
|
+
? translator.t('adminWebhooks.state.active')
|
|
78
|
+
: translator.t('adminWebhooks.state.paused')}
|
|
79
|
+
</span>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="flex flex-wrap gap-2">
|
|
83
|
+
{subscription.topics.map((topic) => (
|
|
84
|
+
<code key={topic} className="font-mono text-xs text-muted-foreground">
|
|
85
|
+
{topic}
|
|
86
|
+
</code>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<p className="text-xs text-muted-foreground">
|
|
91
|
+
<code className="font-mono">{subscription.format}</code>
|
|
92
|
+
{' · '}
|
|
93
|
+
{statusLabel.delivered}: {subscription.delivered}
|
|
94
|
+
{' · '}
|
|
95
|
+
{statusLabel.pending}: {subscription.pending}
|
|
96
|
+
{' · '}
|
|
97
|
+
{statusLabel.dead}: {subscription.dead}
|
|
98
|
+
</p>
|
|
99
|
+
|
|
100
|
+
{subscription.recent.length > 0 && (
|
|
101
|
+
<details>
|
|
102
|
+
<summary className="cursor-pointer text-xs font-medium">
|
|
103
|
+
{translator.t('adminWebhooks.recent')}
|
|
104
|
+
</summary>
|
|
105
|
+
<div className="mt-2 overflow-x-auto">
|
|
106
|
+
<table className="w-full border-collapse text-xs">
|
|
107
|
+
<tbody>
|
|
108
|
+
{subscription.recent.map((entry) => (
|
|
109
|
+
<tr key={entry.id} className="border-b border-border align-top">
|
|
110
|
+
<td className="py-1 pr-3 font-mono">{entry.topic}</td>
|
|
111
|
+
<td className="py-1 pr-3">
|
|
112
|
+
{statusLabel[entry.status] ?? entry.status}
|
|
113
|
+
</td>
|
|
114
|
+
<td className="py-1 pr-3">{entry.attempts}</td>
|
|
115
|
+
<td className="py-1 pr-3 font-mono">
|
|
116
|
+
{entry.lastStatusCode ?? entry.lastError ?? ''}
|
|
117
|
+
</td>
|
|
118
|
+
<td className="py-1">
|
|
119
|
+
{formatTime(entry.createdAt, now, translator).label}
|
|
120
|
+
</td>
|
|
121
|
+
</tr>
|
|
122
|
+
))}
|
|
123
|
+
</tbody>
|
|
124
|
+
</table>
|
|
125
|
+
</div>
|
|
126
|
+
</details>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
130
|
+
<ToggleWebhookForm
|
|
131
|
+
webhookId={subscription.id}
|
|
132
|
+
active={subscription.active}
|
|
133
|
+
copy={copy}
|
|
134
|
+
/>
|
|
135
|
+
<DeleteWebhookForm webhookId={subscription.id} copy={copy} />
|
|
136
|
+
</div>
|
|
137
|
+
</li>
|
|
138
|
+
))}
|
|
139
|
+
</ul>
|
|
140
|
+
)}
|
|
141
|
+
</section>
|
|
142
|
+
</PanelPage>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ALL_THREAD_AUTHORS, PUBLIC_CONTENT } from '@meith/core'
|
|
2
|
+
import { currentRequestId } from '@meith/core/logger'
|
|
3
|
+
import { canHoldThreads } from '@meith/forums'
|
|
4
|
+
|
|
5
|
+
import { getContainer } from '@/server/container'
|
|
6
|
+
import { getActor } from '@/server/context'
|
|
7
|
+
import { emitEvent } from '@/server/plugin-view'
|
|
8
|
+
import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
|
|
9
|
+
import { seeOther } from '@/server/see-other'
|
|
10
|
+
|
|
11
|
+
function idFrom(value: string): number | null {
|
|
12
|
+
if (!/^[1-9]\d*$/.test(value)) return null
|
|
13
|
+
const id = Number(value)
|
|
14
|
+
return Number.isSafeInteger(id) ? id : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
18
|
+
if (!isSameOrigin(request)) return crossOriginRefusal()
|
|
19
|
+
|
|
20
|
+
const threadId = idFrom((await params).id)
|
|
21
|
+
const actor = await getActor()
|
|
22
|
+
const { forums, threads, authorizer, subscriptions } = getContainer()
|
|
23
|
+
if (threadId === null || actor.userId === null || subscriptions === null) return seeOther('/')
|
|
24
|
+
|
|
25
|
+
const thread = await threads.findById(threadId, PUBLIC_CONTENT, ALL_THREAD_AUTHORS)
|
|
26
|
+
if (!thread) return seeOther('/')
|
|
27
|
+
const forum = await forums.findById(thread.forumId)
|
|
28
|
+
if (!forum || !canHoldThreads(forum.type)) return seeOther('/')
|
|
29
|
+
const matrix = await authorizer.forumMatrix(actor, forum.id)
|
|
30
|
+
const target = {
|
|
31
|
+
...(await authorizer.moderatorTargetIn(actor, forum.id, matrix)),
|
|
32
|
+
threadAuthorId: thread.authorUserId,
|
|
33
|
+
}
|
|
34
|
+
if (!authorizer.can(actor, 'thread.view', target)) return seeOther('/')
|
|
35
|
+
|
|
36
|
+
const subscribed = await subscriptions.subscribe({
|
|
37
|
+
userId: actor.userId,
|
|
38
|
+
target: 'thread',
|
|
39
|
+
targetId: threadId,
|
|
40
|
+
mode: 'instant',
|
|
41
|
+
at: new Date(),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (subscribed) {
|
|
45
|
+
await emitEvent(
|
|
46
|
+
'subscription.changed',
|
|
47
|
+
{ userId: actor.userId, target: 'thread', targetId: threadId, subscribed: true },
|
|
48
|
+
{ requestId: currentRequestId() ?? null },
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return seeOther(`/thread/${threadId}-${thread.slug}`)
|
|
53
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ALL_THREAD_AUTHORS, PUBLIC_CONTENT } from '@meith/core'
|
|
2
|
+
import { currentRequestId } from '@meith/core/logger'
|
|
3
|
+
|
|
4
|
+
import { getContainer } from '@/server/container'
|
|
5
|
+
import { getActor } from '@/server/context'
|
|
6
|
+
import { emitEvent } from '@/server/plugin-view'
|
|
7
|
+
import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
|
|
8
|
+
import { seeOther } from '@/server/see-other'
|
|
9
|
+
|
|
10
|
+
function idFrom(value: string): number | null {
|
|
11
|
+
if (!/^[1-9]\d*$/.test(value)) return null
|
|
12
|
+
const id = Number(value)
|
|
13
|
+
return Number.isSafeInteger(id) ? id : null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
17
|
+
if (!isSameOrigin(request)) return crossOriginRefusal()
|
|
18
|
+
|
|
19
|
+
const threadId = idFrom((await params).id)
|
|
20
|
+
const actor = await getActor()
|
|
21
|
+
const { threads, subscriptions } = getContainer()
|
|
22
|
+
if (threadId === null || actor.userId === null || subscriptions === null) return seeOther('/')
|
|
23
|
+
|
|
24
|
+
const thread = await threads.findById(threadId, PUBLIC_CONTENT, ALL_THREAD_AUTHORS)
|
|
25
|
+
if (!thread) return seeOther('/')
|
|
26
|
+
|
|
27
|
+
const unsubscribed = await subscriptions.unsubscribe({
|
|
28
|
+
userId: actor.userId,
|
|
29
|
+
target: 'thread',
|
|
30
|
+
targetId: threadId,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
if (unsubscribed) {
|
|
34
|
+
await emitEvent(
|
|
35
|
+
'subscription.changed',
|
|
36
|
+
{ userId: actor.userId, target: 'thread', targetId: threadId, subscribed: false },
|
|
37
|
+
{ requestId: currentRequestId() ?? null },
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return seeOther(`/thread/${threadId}-${thread.slug}`)
|
|
42
|
+
}
|
package/app/atom.xml/route.ts
CHANGED
|
@@ -2,6 +2,6 @@ import { boardFeed } from '@/server/feed-routes'
|
|
|
2
2
|
|
|
3
3
|
export const dynamic = 'force-dynamic'
|
|
4
4
|
|
|
5
|
-
export async function GET(): Promise<Response> {
|
|
6
|
-
return boardFeed('atom', '/atom.xml')
|
|
5
|
+
export async function GET(request: Request): Promise<Response> {
|
|
6
|
+
return boardFeed('atom', '/atom.xml', request)
|
|
7
7
|
}
|
|
@@ -23,6 +23,7 @@ import { isTopLevelNavigation } from '@/server/same-origin'
|
|
|
23
23
|
import {
|
|
24
24
|
clearHandshakeCookie,
|
|
25
25
|
readHandshakeCookie,
|
|
26
|
+
setSecondFactorCookie,
|
|
26
27
|
setSessionCookie,
|
|
27
28
|
} from '@/server/session-cookies'
|
|
28
29
|
import { decodeHandshake } from '@/server/sso-handshake'
|
|
@@ -42,6 +43,10 @@ function withReason(path: string, reason: string): string {
|
|
|
42
43
|
return `${path}${path.includes('?') ? '&' : '?'}sso=${reason}`
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function verifyPath(next: string): string {
|
|
47
|
+
return next === '/' ? '/login/verify' : `/login/verify?next=${encodeURIComponent(next)}`
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
export async function GET(
|
|
46
51
|
request: NextRequest,
|
|
47
52
|
context: { params: Promise<{ provider: string }> },
|
|
@@ -146,6 +151,11 @@ export async function GET(
|
|
|
146
151
|
},
|
|
147
152
|
})
|
|
148
153
|
|
|
154
|
+
if (outcome.status === 'second-factor') {
|
|
155
|
+
await setSecondFactorCookie(outcome.token, outcome.expiresAt)
|
|
156
|
+
return to(verifyPath(handshake.next))
|
|
157
|
+
}
|
|
158
|
+
|
|
149
159
|
if (outcome.status === 'pending') {
|
|
150
160
|
if (outcome.verificationToken === null) return to(withReason(home, 'approval'))
|
|
151
161
|
|
package/app/feed.xml/route.ts
CHANGED
|
@@ -2,6 +2,6 @@ import { boardFeed } from '@/server/feed-routes'
|
|
|
2
2
|
|
|
3
3
|
export const dynamic = 'force-dynamic'
|
|
4
4
|
|
|
5
|
-
export async function GET(): Promise<Response> {
|
|
6
|
-
return boardFeed('rss', '/feed.xml')
|
|
5
|
+
export async function GET(request: Request): Promise<Response> {
|
|
6
|
+
return boardFeed('rss', '/feed.xml', request)
|
|
7
7
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { BOARD_TITLE } from '@/view/board-title'
|
|
2
|
+
|
|
3
|
+
export const dynamic = 'force-static'
|
|
4
|
+
|
|
5
|
+
function page(): string {
|
|
6
|
+
return `<!doctype html>
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<title>Offline · ${BOARD_TITLE}</title>
|
|
12
|
+
<style>
|
|
13
|
+
:root {
|
|
14
|
+
--offline-background: oklch(0.968 0 0);
|
|
15
|
+
--offline-foreground: oklch(0.205 0 0);
|
|
16
|
+
--offline-card: oklch(1 0 0);
|
|
17
|
+
--offline-border: oklch(0.905 0 0);
|
|
18
|
+
--offline-primary: oklch(0.508 0.105 165.6);
|
|
19
|
+
--offline-primary-foreground: oklch(0.985 0 0);
|
|
20
|
+
--offline-muted-foreground: oklch(0.494 0 0);
|
|
21
|
+
--offline-radius: 0.5rem;
|
|
22
|
+
color-scheme: light dark;
|
|
23
|
+
}
|
|
24
|
+
@media (prefers-color-scheme: dark) {
|
|
25
|
+
:root {
|
|
26
|
+
--offline-background: oklch(0.15 0 0);
|
|
27
|
+
--offline-foreground: oklch(0.967 0 0);
|
|
28
|
+
--offline-card: oklch(0.196 0 0);
|
|
29
|
+
--offline-border: oklch(0.31 0 0);
|
|
30
|
+
--offline-primary: oklch(0.773 0.153 163.2);
|
|
31
|
+
--offline-primary-foreground: oklch(0.181 0.029 166.6);
|
|
32
|
+
--offline-muted-foreground: oklch(0.702 0 0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
* {
|
|
36
|
+
box-sizing: border-box;
|
|
37
|
+
}
|
|
38
|
+
body {
|
|
39
|
+
margin: 0;
|
|
40
|
+
min-height: 100vh;
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
justify-content: center;
|
|
44
|
+
padding: 1.5rem;
|
|
45
|
+
background: var(--offline-background);
|
|
46
|
+
color: var(--offline-foreground);
|
|
47
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
48
|
+
}
|
|
49
|
+
main {
|
|
50
|
+
width: 100%;
|
|
51
|
+
max-width: 24rem;
|
|
52
|
+
padding: 2rem;
|
|
53
|
+
text-align: center;
|
|
54
|
+
background: var(--offline-card);
|
|
55
|
+
border: 1px solid var(--offline-border);
|
|
56
|
+
border-radius: var(--offline-radius);
|
|
57
|
+
}
|
|
58
|
+
h1 {
|
|
59
|
+
margin: 0 0 0.5rem;
|
|
60
|
+
font-size: 1.125rem;
|
|
61
|
+
font-weight: 600;
|
|
62
|
+
}
|
|
63
|
+
p {
|
|
64
|
+
margin: 0 0 1.5rem;
|
|
65
|
+
color: var(--offline-muted-foreground);
|
|
66
|
+
font-size: 0.9375rem;
|
|
67
|
+
line-height: 1.5;
|
|
68
|
+
}
|
|
69
|
+
a.retry {
|
|
70
|
+
display: inline-block;
|
|
71
|
+
padding: 0.5rem 1.25rem;
|
|
72
|
+
border-radius: var(--offline-radius);
|
|
73
|
+
background: var(--offline-primary);
|
|
74
|
+
color: var(--offline-primary-foreground);
|
|
75
|
+
font-weight: 600;
|
|
76
|
+
text-decoration: none;
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<main>
|
|
82
|
+
<h1>${BOARD_TITLE}</h1>
|
|
83
|
+
<p>You're offline. Check your connection and try again.</p>
|
|
84
|
+
<a class="retry" href="/">Retry</a>
|
|
85
|
+
</main>
|
|
86
|
+
</body>
|
|
87
|
+
</html>
|
|
88
|
+
`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function GET(): Response {
|
|
92
|
+
return new Response(page(), {
|
|
93
|
+
headers: {
|
|
94
|
+
'content-type': 'text/html; charset=utf-8',
|
|
95
|
+
'cache-control': 'public, max-age=300',
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
}
|