@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
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { useActionState } from 'react'
|
|
4
4
|
|
|
5
|
-
import { Card, CardContent, Field, Textarea } from '@meith/ui'
|
|
5
|
+
import { Card, CardContent, Field, Textarea, textLinkVariants } from '@meith/ui'
|
|
6
6
|
|
|
7
7
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
8
8
|
import { assignReportAction, closeReportAction, fileReportAction } from '@/server/report-actions'
|
|
9
|
+
import { REPORT_CATEGORY_LABEL_KEYS, REPORT_CATEGORY_ORDER } from '@/view/moderation-copy'
|
|
9
10
|
|
|
10
11
|
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
11
12
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
@@ -29,14 +30,32 @@ export function ReportForm({
|
|
|
29
30
|
<input type="hidden" name="kind" value={kind} />
|
|
30
31
|
<input type="hidden" name="targetId" value={targetId} />
|
|
31
32
|
|
|
33
|
+
<fieldset className="flex flex-col gap-2">
|
|
34
|
+
<legend className="mb-1 text-sm font-medium leading-none text-foreground">
|
|
35
|
+
{fromCopy(copy, 'moderationForm.report.categoryLegend')}
|
|
36
|
+
</legend>
|
|
37
|
+
<div className="flex flex-col gap-1.5">
|
|
38
|
+
{REPORT_CATEGORY_ORDER.map((category) => (
|
|
39
|
+
<label key={category} className="flex items-center gap-2 text-sm">
|
|
40
|
+
<input
|
|
41
|
+
type="radio"
|
|
42
|
+
name="category"
|
|
43
|
+
value={category}
|
|
44
|
+
defaultChecked={state.values?.category === category}
|
|
45
|
+
className="size-4 accent-primary"
|
|
46
|
+
/>
|
|
47
|
+
{fromCopy(copy, REPORT_CATEGORY_LABEL_KEYS[category])}
|
|
48
|
+
</label>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</fieldset>
|
|
52
|
+
|
|
32
53
|
<Field
|
|
33
54
|
name="reason"
|
|
34
55
|
label={fromCopy(copy, 'moderationForm.report.label')}
|
|
35
56
|
description={fromCopy(copy, 'moderationForm.report.hint')}
|
|
36
57
|
>
|
|
37
|
-
{(control) =>
|
|
38
|
-
<Textarea {...control} required defaultValue={state.values?.reason ?? ''} />
|
|
39
|
-
)}
|
|
58
|
+
{(control) => <Textarea {...control} defaultValue={state.values?.reason ?? ''} />}
|
|
40
59
|
</Field>
|
|
41
60
|
|
|
42
61
|
<div>
|
|
@@ -68,7 +87,7 @@ export function AssignReportForm({
|
|
|
68
87
|
<input type="hidden" name="take" value={mine ? '0' : '1'} />
|
|
69
88
|
<PendingButton
|
|
70
89
|
showWorking
|
|
71
|
-
className=
|
|
90
|
+
className={`${textLinkVariants({ size: 'xs' })} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`}
|
|
72
91
|
>
|
|
73
92
|
{mine
|
|
74
93
|
? fromCopy(copy, 'moderationForm.report.putBack')
|
|
@@ -6,6 +6,7 @@ import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
|
6
6
|
import { threadToolAction } from '@/server/thread-tool-actions'
|
|
7
7
|
|
|
8
8
|
import { FormError, PendingButton } from '../auth/form-controls'
|
|
9
|
+
import { ConfirmDialog } from '../shell/confirm-dialog'
|
|
9
10
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
10
11
|
|
|
11
12
|
const BUTTON =
|
|
@@ -101,6 +102,7 @@ export function ThreadToolsForm({
|
|
|
101
102
|
</div>
|
|
102
103
|
|
|
103
104
|
<FormError message={state.error} />
|
|
105
|
+
<ConfirmDialog confirm={state.confirm} action={action} />
|
|
104
106
|
</section>
|
|
105
107
|
)
|
|
106
108
|
}
|
|
@@ -7,7 +7,9 @@ import { getTranslator } from '@/server/i18n'
|
|
|
7
7
|
import { filterView, viewerRef } from '@/server/plugin-view'
|
|
8
8
|
import { currentTheme } from '@/server/theme'
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import { NoticeToast } from './notice-toast'
|
|
11
|
+
|
|
12
|
+
async function NoticeBanner({ kind, message, dismissHref }: NoticeModel) {
|
|
11
13
|
const theme = await currentTheme()
|
|
12
14
|
const Notice = requireSlot(theme, 'Notice')
|
|
13
15
|
|
|
@@ -19,3 +21,22 @@ export async function BoardNotice({ kind, message, dismissHref }: NoticeModel) {
|
|
|
19
21
|
|
|
20
22
|
return <Notice {...model} copy={slotCopy(theme, 'Notice', await getTranslator())} />
|
|
21
23
|
}
|
|
24
|
+
|
|
25
|
+
export async function BoardNotice({ kind, message, dismissHref }: NoticeModel) {
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<noscript
|
|
29
|
+
dangerouslySetInnerHTML={{
|
|
30
|
+
__html:
|
|
31
|
+
'<style>[data-notice="toast"]{display:none!important}[data-notice="banner"]{display:block!important}</style>',
|
|
32
|
+
}}
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
<NoticeToast kind={kind} message={message} dismissHref={dismissHref} />
|
|
36
|
+
|
|
37
|
+
<div data-notice="banner" className="hidden">
|
|
38
|
+
<NoticeBanner kind={kind} message={message} dismissHref={dismissHref} />
|
|
39
|
+
</div>
|
|
40
|
+
</>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { AlertDialog } from '@meith/ui/dialog'
|
|
6
|
+
|
|
7
|
+
import type { ConfirmRequest } from '@/server/auth-form-state'
|
|
8
|
+
|
|
9
|
+
import { PendingButton } from '../auth/form-controls'
|
|
10
|
+
import { fromCopy, useCopy } from './copy'
|
|
11
|
+
|
|
12
|
+
const CONFIRM_BUTTON =
|
|
13
|
+
'inline-flex h-9 items-center justify-center rounded-md border border-destructive/30 bg-destructive/10 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-60'
|
|
14
|
+
|
|
15
|
+
const CANCEL_BUTTON =
|
|
16
|
+
'inline-flex h-9 items-center justify-center rounded-md border border-border px-3 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
17
|
+
|
|
18
|
+
export function ConfirmDialog({
|
|
19
|
+
confirm,
|
|
20
|
+
action,
|
|
21
|
+
}: {
|
|
22
|
+
confirm: ConfirmRequest | undefined
|
|
23
|
+
action: (formData: FormData) => void
|
|
24
|
+
}) {
|
|
25
|
+
const copy = useCopy()
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (confirm !== undefined) setOpen(true)
|
|
30
|
+
}, [confirm])
|
|
31
|
+
|
|
32
|
+
if (confirm === undefined) return null
|
|
33
|
+
|
|
34
|
+
const hidden = confirm.fields.map((field, index) => {
|
|
35
|
+
const fieldKey = `${index}:${field.name}`
|
|
36
|
+
return <input key={fieldKey} type="hidden" name={field.name} value={field.value} />
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const confirmLabel = fromCopy(copy, 'confirm.confirm')
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<noscript
|
|
44
|
+
dangerouslySetInnerHTML={{
|
|
45
|
+
__html:
|
|
46
|
+
'<style>[data-confirm="dialog"]{display:none}[data-confirm="plain"]{display:flex!important}</style>',
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
|
|
50
|
+
<span data-confirm="dialog">
|
|
51
|
+
<AlertDialog.Root open={open} onOpenChange={setOpen}>
|
|
52
|
+
<AlertDialog.Portal>
|
|
53
|
+
<AlertDialog.Backdrop className="fixed inset-0 z-50 bg-foreground/40 transition-opacity duration-100 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0" />
|
|
54
|
+
<AlertDialog.Popup className="fixed left-1/2 top-1/2 z-50 flex w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg transition-[opacity,transform] duration-100 ease-out data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0">
|
|
55
|
+
<AlertDialog.Title className="text-base font-semibold">
|
|
56
|
+
{fromCopy(copy, 'confirm.heading')}
|
|
57
|
+
</AlertDialog.Title>
|
|
58
|
+
<AlertDialog.Description className="text-sm text-muted-foreground">
|
|
59
|
+
{confirm.message}
|
|
60
|
+
</AlertDialog.Description>
|
|
61
|
+
<div className="flex justify-end gap-2">
|
|
62
|
+
<AlertDialog.Close className={CANCEL_BUTTON}>
|
|
63
|
+
{fromCopy(copy, 'confirm.cancel')}
|
|
64
|
+
</AlertDialog.Close>
|
|
65
|
+
<form action={action}>
|
|
66
|
+
{hidden}
|
|
67
|
+
<input type="hidden" name="confirmed" value="1" />
|
|
68
|
+
<PendingButton showWorking className={CONFIRM_BUTTON}>
|
|
69
|
+
{confirmLabel}
|
|
70
|
+
</PendingButton>
|
|
71
|
+
</form>
|
|
72
|
+
</div>
|
|
73
|
+
</AlertDialog.Popup>
|
|
74
|
+
</AlertDialog.Portal>
|
|
75
|
+
</AlertDialog.Root>
|
|
76
|
+
</span>
|
|
77
|
+
|
|
78
|
+
<div
|
|
79
|
+
data-confirm="plain"
|
|
80
|
+
className="hidden flex-col gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-4"
|
|
81
|
+
>
|
|
82
|
+
<p className="text-sm text-card-foreground">{confirm.message}</p>
|
|
83
|
+
<form action={action}>
|
|
84
|
+
{hidden}
|
|
85
|
+
<input type="hidden" name="confirmed" value="1" />
|
|
86
|
+
<PendingButton showWorking className={CONFIRM_BUTTON}>
|
|
87
|
+
{confirmLabel}
|
|
88
|
+
</PendingButton>
|
|
89
|
+
</form>
|
|
90
|
+
</div>
|
|
91
|
+
</>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
const OPEN_DISCLOSURES = '[data-nav-disclosure][open]'
|
|
6
|
+
|
|
7
|
+
export function NavDisclosureEnhancer() {
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
function onPointerDown(event: PointerEvent) {
|
|
10
|
+
const target = event.target
|
|
11
|
+
if (!(target instanceof Node)) return
|
|
12
|
+
|
|
13
|
+
for (const details of document.querySelectorAll<HTMLDetailsElement>(OPEN_DISCLOSURES)) {
|
|
14
|
+
if (!details.contains(target)) details.open = false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
19
|
+
if (event.key !== 'Escape') return
|
|
20
|
+
|
|
21
|
+
for (const details of document.querySelectorAll<HTMLDetailsElement>(OPEN_DISCLOSURES)) {
|
|
22
|
+
details.open = false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
27
|
+
document.addEventListener('keydown', onKeyDown)
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
31
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
32
|
+
}
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { Toast, type ToastTone } from '@meith/ui/toast'
|
|
6
|
+
|
|
7
|
+
import { fromCopy, useCopy } from './copy'
|
|
8
|
+
|
|
9
|
+
export function NoticeToast({
|
|
10
|
+
kind,
|
|
11
|
+
message,
|
|
12
|
+
dismissHref,
|
|
13
|
+
}: {
|
|
14
|
+
kind: ToastTone
|
|
15
|
+
message: string
|
|
16
|
+
dismissHref: string | null
|
|
17
|
+
}) {
|
|
18
|
+
const copy = useCopy()
|
|
19
|
+
const [mounted, setMounted] = useState(false)
|
|
20
|
+
|
|
21
|
+
useEffect(() => setMounted(true), [])
|
|
22
|
+
|
|
23
|
+
const clearNotice = useCallback(() => {
|
|
24
|
+
if (dismissHref === null || typeof window === 'undefined') return
|
|
25
|
+
window.history.replaceState(window.history.state, '', dismissHref)
|
|
26
|
+
}, [dismissHref])
|
|
27
|
+
|
|
28
|
+
if (!mounted) return null
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
data-notice="toast"
|
|
33
|
+
className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4"
|
|
34
|
+
>
|
|
35
|
+
<Toast tone={kind} dismissLabel={fromCopy(copy, 'notice.dismiss')} onDismiss={clearNotice}>
|
|
36
|
+
{message}
|
|
37
|
+
</Toast>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useActionState } from 'react'
|
|
4
4
|
|
|
5
|
+
import { TextLink } from '@meith/ui'
|
|
5
6
|
import { Popover } from '@meith/ui/popover'
|
|
6
7
|
import { Tabs } from '@meith/ui/tabs'
|
|
7
8
|
|
|
@@ -223,12 +224,13 @@ function Panel({ tab, copy }: { tab: NotificationMenuTab; copy: Copy }) {
|
|
|
223
224
|
)}
|
|
224
225
|
|
|
225
226
|
<div className="flex items-center justify-between gap-2 border-t border-border px-2 pt-2">
|
|
226
|
-
<
|
|
227
|
+
<TextLink
|
|
227
228
|
href={tab.allHref}
|
|
228
|
-
|
|
229
|
+
size="xs"
|
|
230
|
+
className="rounded-md px-2 py-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
229
231
|
>
|
|
230
232
|
{fromCopy(copy, 'board.notificationMenu.viewAll')}
|
|
231
|
-
</
|
|
233
|
+
</TextLink>
|
|
232
234
|
|
|
233
235
|
{unreadRows.length > 0 && (
|
|
234
236
|
<SeenForm
|
|
@@ -4,6 +4,7 @@ import { requireSlot, slotCopy } from '@meith/theme-kit'
|
|
|
4
4
|
|
|
5
5
|
import { LogoutForm } from '@/components/account/logout-form'
|
|
6
6
|
import { InstallBanner } from '@/components/shell/install-banner'
|
|
7
|
+
import { NavDisclosureEnhancer } from '@/components/shell/nav-disclosure-enhancer'
|
|
7
8
|
import { NotificationMenu } from '@/components/shell/notification-menu'
|
|
8
9
|
import { OnboardingBanner } from '@/components/shell/onboarding-banner'
|
|
9
10
|
import { ThemeSwitcher } from '@/components/shell/theme-switcher'
|
|
@@ -152,6 +153,8 @@ export async function PageShell({ actor, children }: { actor: Actor; children: R
|
|
|
152
153
|
</UserPanel>
|
|
153
154
|
</Header>
|
|
154
155
|
|
|
156
|
+
<NavDisclosureEnhancer />
|
|
157
|
+
|
|
155
158
|
{await boardRegion('header.notice', actor)}
|
|
156
159
|
|
|
157
160
|
{install !== null && <InstallBanner {...install} />}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useActionState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { setSchemeAction } from '@/server/appearance-actions'
|
|
6
|
+
import {
|
|
7
|
+
COLOUR_SCHEMES,
|
|
8
|
+
type ColourSchemePreference,
|
|
9
|
+
colorSchemeProperty,
|
|
10
|
+
schemeClass,
|
|
11
|
+
} from '@/view/theme-preference'
|
|
12
|
+
|
|
13
|
+
import { PendingButton } from '../auth/form-controls'
|
|
14
|
+
import { ProgressiveMarker } from '../content/progressive-marker'
|
|
15
|
+
|
|
16
|
+
const EMPTY: { scheme?: ColourSchemePreference } = {}
|
|
17
|
+
|
|
18
|
+
function applySchemeImmediately(scheme: ColourSchemePreference): void {
|
|
19
|
+
const root = document.documentElement
|
|
20
|
+
root.classList.remove('light', 'dark')
|
|
21
|
+
const applied = schemeClass(scheme)
|
|
22
|
+
if (applied !== '') root.classList.add(applied)
|
|
23
|
+
root.style.colorScheme = colorSchemeProperty(scheme)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function SchemeToggle({
|
|
27
|
+
scheme,
|
|
28
|
+
groupLabel,
|
|
29
|
+
labels,
|
|
30
|
+
icons,
|
|
31
|
+
}: {
|
|
32
|
+
scheme: ColourSchemePreference
|
|
33
|
+
groupLabel: string
|
|
34
|
+
labels: Readonly<Record<ColourSchemePreference, string>>
|
|
35
|
+
icons: Readonly<Record<ColourSchemePreference, React.ReactNode>>
|
|
36
|
+
}) {
|
|
37
|
+
const [state, action] = useActionState(setSchemeAction, EMPTY)
|
|
38
|
+
const current = state.scheme ?? scheme
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<form action={action}>
|
|
42
|
+
<ProgressiveMarker />
|
|
43
|
+
<span
|
|
44
|
+
role="group"
|
|
45
|
+
aria-label={groupLabel}
|
|
46
|
+
className="inline-flex items-center overflow-hidden rounded-md border border-border text-muted-foreground"
|
|
47
|
+
>
|
|
48
|
+
{COLOUR_SCHEMES.map((option) => {
|
|
49
|
+
const selected = current === option
|
|
50
|
+
return (
|
|
51
|
+
<PendingButton
|
|
52
|
+
key={option}
|
|
53
|
+
name="scheme"
|
|
54
|
+
value={option}
|
|
55
|
+
title={labels[option]}
|
|
56
|
+
aria-pressed={selected}
|
|
57
|
+
onClick={() => applySchemeImmediately(option)}
|
|
58
|
+
className={`inline-flex h-8 items-center justify-center border-border px-2.5 transition-colors [&:not(:first-child)]:border-l focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${
|
|
59
|
+
selected
|
|
60
|
+
? 'bg-primary text-primary-foreground'
|
|
61
|
+
: 'bg-background hover:bg-accent hover:text-accent-foreground'
|
|
62
|
+
}`}
|
|
63
|
+
>
|
|
64
|
+
{icons[option]}
|
|
65
|
+
<span className="sr-only">{labels[option]}</span>
|
|
66
|
+
</PendingButton>
|
|
67
|
+
)
|
|
68
|
+
})}
|
|
69
|
+
</span>
|
|
70
|
+
</form>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import { PendingButton } from '@/components/auth/form-controls'
|
|
2
2
|
import { MonitorIcon, MoonIcon, SunIcon } from '@/components/shell/appearance-icons'
|
|
3
|
+
import { SchemeToggle } from '@/components/shell/scheme-toggle'
|
|
3
4
|
import { setAppearanceAction } from '@/server/appearance-actions'
|
|
4
5
|
import { getTranslator } from '@/server/i18n'
|
|
5
6
|
import { currentColourScheme, currentThemeKey } from '@/server/theme'
|
|
6
7
|
import { getBoardThemeStyle } from '@/server/theme-runtime'
|
|
7
8
|
import { COLOUR_SCHEMES, type ColourSchemePreference } from '@/view/theme-preference'
|
|
8
9
|
|
|
9
|
-
const SCHEME_ICON: Record<ColourSchemePreference, () => React.ReactNode> = {
|
|
10
|
-
light: SunIcon,
|
|
11
|
-
system: MonitorIcon,
|
|
12
|
-
dark: MoonIcon,
|
|
13
|
-
}
|
|
14
|
-
|
|
15
10
|
const SCHEME_LABEL_KEY: Record<ColourSchemePreference, string> = {
|
|
16
11
|
system: 'appearance.scheme.system',
|
|
17
12
|
light: 'appearance.scheme.light',
|
|
@@ -29,6 +24,15 @@ export async function ThemeSwitcher() {
|
|
|
29
24
|
getTranslator(),
|
|
30
25
|
])
|
|
31
26
|
|
|
27
|
+
const icons: Record<ColourSchemePreference, React.ReactNode> = {
|
|
28
|
+
light: <SunIcon />,
|
|
29
|
+
system: <MonitorIcon />,
|
|
30
|
+
dark: <MoonIcon />,
|
|
31
|
+
}
|
|
32
|
+
const labels = Object.fromEntries(
|
|
33
|
+
COLOUR_SCHEMES.map((option) => [option, t.t(SCHEME_LABEL_KEY[option])]),
|
|
34
|
+
) as Record<ColourSchemePreference, string>
|
|
35
|
+
|
|
32
36
|
return (
|
|
33
37
|
<section
|
|
34
38
|
aria-label={t.t('appearance.section')}
|
|
@@ -55,36 +59,12 @@ export async function ThemeSwitcher() {
|
|
|
55
59
|
</form>
|
|
56
60
|
)}
|
|
57
61
|
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
{COLOUR_SCHEMES.map((option) => {
|
|
65
|
-
const Icon = SCHEME_ICON[option]
|
|
66
|
-
const label = t.t(SCHEME_LABEL_KEY[option])
|
|
67
|
-
const selected = scheme === option
|
|
68
|
-
return (
|
|
69
|
-
<PendingButton
|
|
70
|
-
key={option}
|
|
71
|
-
name="scheme"
|
|
72
|
-
value={option}
|
|
73
|
-
title={label}
|
|
74
|
-
aria-pressed={selected}
|
|
75
|
-
className={`inline-flex h-8 items-center justify-center border-border px-2.5 transition-colors [&:not(:first-child)]:border-l focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${
|
|
76
|
-
selected
|
|
77
|
-
? 'bg-primary text-primary-foreground'
|
|
78
|
-
: 'bg-background hover:bg-accent hover:text-accent-foreground'
|
|
79
|
-
}`}
|
|
80
|
-
>
|
|
81
|
-
<Icon />
|
|
82
|
-
<span className="sr-only">{label}</span>
|
|
83
|
-
</PendingButton>
|
|
84
|
-
)
|
|
85
|
-
})}
|
|
86
|
-
</span>
|
|
87
|
-
</form>
|
|
62
|
+
<SchemeToggle
|
|
63
|
+
scheme={scheme}
|
|
64
|
+
groupLabel={t.t('appearance.schemeGroup')}
|
|
65
|
+
labels={labels}
|
|
66
|
+
icons={icons}
|
|
67
|
+
/>
|
|
88
68
|
</section>
|
|
89
69
|
)
|
|
90
70
|
}
|
|
@@ -18,6 +18,7 @@ import { recordAdminAction, requireAdmin } from './admin'
|
|
|
18
18
|
import type { FormState } from './auth-form-state'
|
|
19
19
|
import { tr } from './i18n'
|
|
20
20
|
import { emitEvent } from './plugin-view'
|
|
21
|
+
import { searchProvider } from './search'
|
|
21
22
|
import { getSettings } from './settings'
|
|
22
23
|
|
|
23
24
|
function submittedKeys(form: FormData): readonly SettingDefinition[] {
|
|
@@ -59,6 +60,10 @@ export async function saveAdminSettingsAction(
|
|
|
59
60
|
await getSettings(),
|
|
60
61
|
)
|
|
61
62
|
|
|
63
|
+
if (result.changed.includes('search.language')) {
|
|
64
|
+
await searchProvider()?.markForReindex()
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
if (result.changed.length > 0) {
|
|
63
68
|
const tags = [CacheTags.settings(), ...result.invalidates]
|
|
64
69
|
await drivers().cache.invalidateTags(tags)
|
package/src/server/api/search.ts
CHANGED
|
@@ -13,7 +13,13 @@ import {
|
|
|
13
13
|
|
|
14
14
|
import { filterWords } from '../../view/word-filter'
|
|
15
15
|
import { activeWordFilter } from '../content-admin'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
requireSearch,
|
|
18
|
+
requireSearchEnabled,
|
|
19
|
+
searchLanguage,
|
|
20
|
+
searchMinWordLength,
|
|
21
|
+
searchScopeFor,
|
|
22
|
+
} from '../search'
|
|
17
23
|
import {
|
|
18
24
|
type ApiResult,
|
|
19
25
|
type ApiRoutes,
|
|
@@ -55,7 +61,7 @@ export const SEARCH_HANDLERS: ApiRoutes = [
|
|
|
55
61
|
const forumIds = ids('forum')
|
|
56
62
|
const authorUserIds = ids('by')
|
|
57
63
|
|
|
58
|
-
const results = await requireSearch().search(
|
|
64
|
+
const results = await requireSearch(await searchLanguage()).search(
|
|
59
65
|
searchQueryFrom(
|
|
60
66
|
parsed.terms,
|
|
61
67
|
{
|
|
@@ -8,6 +8,7 @@ import { msg } from '@meith/i18n'
|
|
|
8
8
|
import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
|
|
9
9
|
import { apiTokenStore, issueApiToken } from './api-tokens-admin'
|
|
10
10
|
import type { FormState } from './auth-form-state'
|
|
11
|
+
import { requireConfirmation } from './confirm'
|
|
11
12
|
import { getMessageResolver, tr } from './i18n'
|
|
12
13
|
|
|
13
14
|
function field(form: FormData, name: string): string {
|
|
@@ -69,6 +70,9 @@ export async function revokeApiTokenAction(_prev: FormState, form: FormData): Pr
|
|
|
69
70
|
const id = Number(field(form, 'tokenId'))
|
|
70
71
|
if (!Number.isSafeInteger(id) || id <= 0) return { error: await tr('notice.app.such-token') }
|
|
71
72
|
|
|
73
|
+
const confirm = requireConfirmation(form, await tr('adminPanel.token.confirmRevoke'))
|
|
74
|
+
if (confirm !== null) return confirm
|
|
75
|
+
|
|
72
76
|
const store = apiTokenStore()
|
|
73
77
|
if (store === null) return { error: await tr('notice.app.board-database-api') }
|
|
74
78
|
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { cookies } from 'next/headers'
|
|
4
4
|
|
|
5
|
+
import { isEnhancedSubmit } from '@/view/progressive-enhancement'
|
|
5
6
|
import {
|
|
7
|
+
type ColourSchemePreference,
|
|
6
8
|
isColourScheme,
|
|
7
9
|
PREFERENCE_COOKIE_MAX_AGE,
|
|
8
10
|
SCHEME_COOKIE,
|
|
@@ -37,3 +39,22 @@ export async function setAppearanceAction(form: FormData): Promise<void> {
|
|
|
37
39
|
|
|
38
40
|
await redirectToCurrentPath()
|
|
39
41
|
}
|
|
42
|
+
|
|
43
|
+
export interface SchemeState {
|
|
44
|
+
readonly scheme?: ColourSchemePreference | undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function setSchemeAction(_prev: SchemeState, form: FormData): Promise<SchemeState> {
|
|
48
|
+
const raw = form.get('scheme')
|
|
49
|
+
const scheme = typeof raw === 'string' && isColourScheme(raw) ? raw : null
|
|
50
|
+
|
|
51
|
+
if (scheme !== null) {
|
|
52
|
+
const jar = await cookies()
|
|
53
|
+
jar.set(SCHEME_COOKIE, scheme, COOKIE_OPTIONS)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isEnhancedSubmit(form)) return { scheme: scheme ?? undefined }
|
|
57
|
+
|
|
58
|
+
await redirectToCurrentPath()
|
|
59
|
+
return {}
|
|
60
|
+
}
|
|
@@ -129,6 +129,41 @@ export async function stageAttachments(
|
|
|
129
129
|
return service.stage(accepted)
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
export async function resolveEditAttachmentScope(
|
|
133
|
+
actor: Actor,
|
|
134
|
+
forumId: number,
|
|
135
|
+
): Promise<AttachmentScope> {
|
|
136
|
+
const { authorizer, threadWrites } = getContainer()
|
|
137
|
+
const rules = threadWrites === null ? null : await threadWrites.postingRules(forumId)
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
forumId,
|
|
141
|
+
forum: await authorizer.forumMatrix(actor, forumId),
|
|
142
|
+
allowsAttachments: rules?.allowAttachments === true,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function removeAttachmentsFromPost(
|
|
147
|
+
ids: readonly number[],
|
|
148
|
+
post: { readonly postId: number; readonly userId: number },
|
|
149
|
+
): Promise<readonly AttachmentRecord[]> {
|
|
150
|
+
const service = attachmentService()
|
|
151
|
+
if (service === null || ids.length === 0) return []
|
|
152
|
+
|
|
153
|
+
const removed: AttachmentRecord[] = []
|
|
154
|
+
for (const id of ids) {
|
|
155
|
+
const record = await service.removeFromPost(id, post.postId)
|
|
156
|
+
if (record === null) continue
|
|
157
|
+
removed.push(record)
|
|
158
|
+
await emitEvent(
|
|
159
|
+
'attachment.deleted',
|
|
160
|
+
{ attachmentId: record.id },
|
|
161
|
+
{ userId: post.userId, isGuest: false },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return removed
|
|
165
|
+
}
|
|
166
|
+
|
|
132
167
|
export async function attachStaged(
|
|
133
168
|
staged: readonly StagedUpload[],
|
|
134
169
|
post: { readonly postId: number; readonly forumId: number; readonly userId: number },
|
|
@@ -20,6 +20,7 @@ import { recordAuthEvent } from './auth-events'
|
|
|
20
20
|
import type { FormState } from './auth-form-state'
|
|
21
21
|
import { sendPasswordResetEmail, sendVerificationEmail } from './auth-mail'
|
|
22
22
|
import { configuredIdentity, configuredSessions, getContainer } from './container'
|
|
23
|
+
import { revokeFeedToken } from './feed-token'
|
|
23
24
|
import { formStateReporter } from './form-state-reporter'
|
|
24
25
|
import { getTranslator, tr } from './i18n'
|
|
25
26
|
import { termsAcceptance } from './legal'
|
|
@@ -477,6 +478,7 @@ export async function confirmResetAction(_prev: FormState, form: FormData): Prom
|
|
|
477
478
|
const identity = await configuredIdentity()
|
|
478
479
|
try {
|
|
479
480
|
const { userId } = await identity.redeemPasswordReset(token, password)
|
|
481
|
+
await revokeFeedToken(userId)
|
|
480
482
|
await recordAuthEvent({ userId, kind: 'password_reset' })
|
|
481
483
|
} catch (err) {
|
|
482
484
|
return toFormState(err, { token })
|
|
@@ -3,12 +3,40 @@ export interface UndoState {
|
|
|
3
3
|
readonly expiresAt: string
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
export interface ConfirmField {
|
|
7
|
+
readonly name: string
|
|
8
|
+
readonly value: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ConfirmRequest {
|
|
12
|
+
readonly message: string
|
|
13
|
+
readonly fields: readonly ConfirmField[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PollDraftValues {
|
|
17
|
+
readonly question: string
|
|
18
|
+
readonly options: readonly string[]
|
|
19
|
+
readonly closesAt: string
|
|
20
|
+
readonly maxOptions: string
|
|
21
|
+
readonly allowRevote: boolean
|
|
22
|
+
readonly publicVotes: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ThanksResult {
|
|
26
|
+
readonly thanked: boolean
|
|
27
|
+
readonly count: number
|
|
28
|
+
}
|
|
29
|
+
|
|
6
30
|
export interface FormState {
|
|
7
31
|
readonly error?: string | undefined
|
|
8
32
|
readonly notice?: string | undefined
|
|
9
33
|
readonly values?: Record<string, string> | undefined
|
|
34
|
+
readonly poll?: PollDraftValues | undefined
|
|
10
35
|
readonly preview?: string | undefined
|
|
11
36
|
readonly undo?: UndoState | undefined
|
|
37
|
+
readonly confirm?: ConfirmRequest | undefined
|
|
38
|
+
readonly thanks?: ThanksResult | undefined
|
|
39
|
+
readonly subscribed?: boolean | undefined
|
|
12
40
|
}
|
|
13
41
|
|
|
14
42
|
export const EMPTY_STATE: FormState = {}
|