@meith/web 0.31.0 → 0.33.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/layout.tsx +23 -8
- 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/admin-forms.tsx +57 -9
- 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/components/shell/view-tabs.tsx +1 -1
- package/src/server/admin-actions.ts +118 -49
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/admin.ts +70 -2
- 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/cookies.ts +18 -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/push-actions.ts +9 -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/session-cookies.ts +18 -0
- 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/styles/globals.css +1 -1
- 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 +29 -1
- 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,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useActionState, useEffect, useState } from 'react'
|
|
4
4
|
|
|
5
|
+
import { textLinkVariants } from '@meith/ui'
|
|
6
|
+
|
|
5
7
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
6
8
|
import {
|
|
7
9
|
importThemeAction,
|
|
@@ -43,8 +45,7 @@ const INPUT =
|
|
|
43
45
|
const GHOST_BUTTON =
|
|
44
46
|
'inline-flex h-8 items-center justify-center rounded-md border border-border px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
45
47
|
|
|
46
|
-
const LINK =
|
|
47
|
-
'text-xs font-medium underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
48
|
+
const LINK = `${textLinkVariants({ tone: 'inherit', size: 'xs' })} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`
|
|
48
49
|
|
|
49
50
|
export type TokenValue = EditableToken
|
|
50
51
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useActionState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
6
|
+
import {
|
|
7
|
+
createWebhookAction,
|
|
8
|
+
deleteWebhookAction,
|
|
9
|
+
toggleWebhookAction,
|
|
10
|
+
} from '@/server/webhook-actions'
|
|
11
|
+
|
|
12
|
+
import { FormError, SubmitButton } from '../auth/form-controls'
|
|
13
|
+
import { type Copy, fromCopy } from '../shell/copy'
|
|
14
|
+
|
|
15
|
+
export function CreateWebhookForm({
|
|
16
|
+
topics,
|
|
17
|
+
formats,
|
|
18
|
+
copy,
|
|
19
|
+
}: {
|
|
20
|
+
topics: readonly string[]
|
|
21
|
+
formats: readonly string[]
|
|
22
|
+
copy: Copy
|
|
23
|
+
}) {
|
|
24
|
+
const [state, action] = useActionState(createWebhookAction, EMPTY_STATE)
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<form action={action} className="flex flex-col gap-4">
|
|
28
|
+
<FormError message={state.error} />
|
|
29
|
+
|
|
30
|
+
{state.notice === 'created' && state.values?.secret !== undefined && (
|
|
31
|
+
<div
|
|
32
|
+
role="status"
|
|
33
|
+
className="flex flex-col gap-2 rounded-md border border-accent bg-post-highlight px-3 py-3"
|
|
34
|
+
>
|
|
35
|
+
<p className="text-sm font-semibold">{fromCopy(copy, 'adminPanel.webhook.copyNow')}</p>
|
|
36
|
+
<code className="block overflow-x-auto rounded-sm bg-card px-2 py-1 font-mono text-sm">
|
|
37
|
+
{state.values.secret}
|
|
38
|
+
</code>
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
<label className="flex flex-col gap-1 text-sm">
|
|
43
|
+
<span className="font-medium">{fromCopy(copy, 'adminPanel.webhook.url')}</span>
|
|
44
|
+
<input
|
|
45
|
+
name="url"
|
|
46
|
+
type="url"
|
|
47
|
+
required
|
|
48
|
+
placeholder={fromCopy(copy, 'adminPanel.webhook.urlPlaceholder')}
|
|
49
|
+
className="rounded-sm border border-input bg-card px-2 py-1"
|
|
50
|
+
/>
|
|
51
|
+
<span className="text-xs text-muted-foreground">
|
|
52
|
+
{fromCopy(copy, 'adminPanel.webhook.urlHint')}
|
|
53
|
+
</span>
|
|
54
|
+
</label>
|
|
55
|
+
|
|
56
|
+
<fieldset className="flex flex-col gap-2">
|
|
57
|
+
<legend className="text-sm font-medium">
|
|
58
|
+
{fromCopy(copy, 'adminPanel.webhook.topics')}
|
|
59
|
+
</legend>
|
|
60
|
+
<span className="text-xs text-muted-foreground">
|
|
61
|
+
{fromCopy(copy, 'adminPanel.webhook.topicsHint')}
|
|
62
|
+
</span>
|
|
63
|
+
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
|
64
|
+
{topics.map((topic) => (
|
|
65
|
+
<label key={topic} className="flex items-center gap-2 text-sm">
|
|
66
|
+
<input type="checkbox" name="topics" value={topic} />
|
|
67
|
+
<code className="font-mono text-xs">{topic}</code>
|
|
68
|
+
</label>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</fieldset>
|
|
72
|
+
|
|
73
|
+
<fieldset className="flex flex-col gap-2">
|
|
74
|
+
<legend className="text-sm font-medium">
|
|
75
|
+
{fromCopy(copy, 'adminPanel.webhook.format')}
|
|
76
|
+
</legend>
|
|
77
|
+
<span className="text-xs text-muted-foreground">
|
|
78
|
+
{fromCopy(copy, 'adminPanel.webhook.formatHint')}
|
|
79
|
+
</span>
|
|
80
|
+
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
|
81
|
+
{formats.map((format, index) => (
|
|
82
|
+
<label key={format} className="flex items-center gap-2 text-sm">
|
|
83
|
+
<input type="radio" name="format" value={format} defaultChecked={index === 0} />
|
|
84
|
+
<span>{fromCopy(copy, `adminPanel.webhook.format.${format}`)}</span>
|
|
85
|
+
</label>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
</fieldset>
|
|
89
|
+
|
|
90
|
+
<label className="flex items-center gap-2 text-sm">
|
|
91
|
+
<input type="checkbox" name="active" value="true" defaultChecked />
|
|
92
|
+
<span className="font-medium">{fromCopy(copy, 'adminPanel.webhook.active')}</span>
|
|
93
|
+
</label>
|
|
94
|
+
|
|
95
|
+
<SubmitButton>{fromCopy(copy, 'adminPanel.webhook.create')}</SubmitButton>
|
|
96
|
+
</form>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function ToggleWebhookForm({
|
|
101
|
+
webhookId,
|
|
102
|
+
active,
|
|
103
|
+
copy,
|
|
104
|
+
}: {
|
|
105
|
+
webhookId: number
|
|
106
|
+
active: boolean
|
|
107
|
+
copy: Copy
|
|
108
|
+
}) {
|
|
109
|
+
const [state, action] = useActionState(toggleWebhookAction, EMPTY_STATE)
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<form action={action}>
|
|
113
|
+
<input type="hidden" name="webhookId" value={webhookId} />
|
|
114
|
+
<input type="hidden" name="active" value={active ? 'false' : 'true'} />
|
|
115
|
+
<SubmitButton>
|
|
116
|
+
{fromCopy(copy, active ? 'adminPanel.webhook.disable' : 'adminPanel.webhook.enable')}
|
|
117
|
+
</SubmitButton>
|
|
118
|
+
{state.error !== undefined && (
|
|
119
|
+
<span className="ml-2 text-xs text-destructive">{state.error}</span>
|
|
120
|
+
)}
|
|
121
|
+
</form>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function DeleteWebhookForm({ webhookId, copy }: { webhookId: number; copy: Copy }) {
|
|
126
|
+
const [state, action] = useActionState(deleteWebhookAction, EMPTY_STATE)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<form action={action}>
|
|
130
|
+
<input type="hidden" name="webhookId" value={webhookId} />
|
|
131
|
+
<SubmitButton>{fromCopy(copy, 'adminPanel.webhook.delete')}</SubmitButton>
|
|
132
|
+
{state.error !== undefined && (
|
|
133
|
+
<span className="ml-2 text-xs text-destructive">{state.error}</span>
|
|
134
|
+
)}
|
|
135
|
+
</form>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useActionState } from 'react'
|
|
4
4
|
|
|
5
|
+
import { TextLink } from '@meith/ui'
|
|
6
|
+
|
|
5
7
|
import { registerAction } from '@/server/auth-actions'
|
|
6
8
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
7
9
|
|
|
@@ -135,14 +137,9 @@ export function RegisterForm({
|
|
|
135
137
|
/>
|
|
136
138
|
<span>
|
|
137
139
|
{copy['authForm.register.termsLead']}
|
|
138
|
-
<
|
|
139
|
-
href={terms.href}
|
|
140
|
-
target="_blank"
|
|
141
|
-
rel="noreferrer"
|
|
142
|
-
className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
|
|
143
|
-
>
|
|
140
|
+
<TextLink href={terms.href} target="_blank" rel="noreferrer">
|
|
144
141
|
{terms.label}
|
|
145
|
-
</
|
|
142
|
+
</TextLink>
|
|
146
143
|
{copy['authForm.register.termsTail']}
|
|
147
144
|
</span>
|
|
148
145
|
</label>
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
|
4
4
|
|
|
5
|
+
import { textLinkVariants } from '@meith/ui'
|
|
6
|
+
|
|
5
7
|
export interface LiveRegionProps {
|
|
6
8
|
readonly children: ReactNode
|
|
7
9
|
readonly refresh: () => Promise<ReactNode>
|
|
@@ -63,7 +65,7 @@ export function LiveRegion({ children, refresh, seconds, label }: LiveRegionProp
|
|
|
63
65
|
<button
|
|
64
66
|
type="button"
|
|
65
67
|
onClick={() => setPaused((was) => !was)}
|
|
66
|
-
className=
|
|
68
|
+
className={textLinkVariants()}
|
|
67
69
|
>
|
|
68
70
|
{paused ? 'Resume' : 'Pause'}
|
|
69
71
|
</button>
|
|
@@ -1,45 +1,116 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useRef } from 'react'
|
|
3
|
+
import { useRef, useState } from 'react'
|
|
4
4
|
|
|
5
5
|
import { maxBytesFor, maxPerPostFor, type UploadLimits } from '@meith/attachments/limits'
|
|
6
6
|
import { ACCEPTED_EXTENSIONS, ATTACHMENT_FIELD } from '@meith/attachments/types'
|
|
7
|
+
import { cn } from '@meith/ui'
|
|
7
8
|
|
|
8
9
|
import { formatBytes } from '@/view/attachments'
|
|
9
10
|
|
|
10
11
|
import { formatFromCopy, fromCopy, useCopy } from '../shell/copy'
|
|
11
12
|
|
|
13
|
+
interface ChosenFile {
|
|
14
|
+
readonly id: string
|
|
15
|
+
readonly file: File
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function filesFrom(input: HTMLInputElement | null): readonly File[] {
|
|
19
|
+
return input === null ? [] : Array.from(input.files ?? [])
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
export function AttachmentField({ limits }: { limits: UploadLimits }) {
|
|
13
23
|
const input = useRef<HTMLInputElement>(null)
|
|
14
24
|
const copy = useCopy()
|
|
15
25
|
const perPost = maxPerPostFor(limits)
|
|
16
26
|
const maxBytes = maxBytesFor(limits)
|
|
17
27
|
|
|
28
|
+
const [chosen, setChosen] = useState<readonly ChosenFile[]>([])
|
|
29
|
+
const [dragging, setDragging] = useState(false)
|
|
30
|
+
|
|
31
|
+
function sync(): void {
|
|
32
|
+
setChosen(filesFrom(input.current).map((file) => ({ id: crypto.randomUUID(), file })))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function addFiles(added: FileList): void {
|
|
36
|
+
if (input.current === null) return
|
|
37
|
+
const next = new DataTransfer()
|
|
38
|
+
for (const file of filesFrom(input.current)) next.items.add(file)
|
|
39
|
+
for (const file of added) next.items.add(file)
|
|
40
|
+
input.current.files = next.files
|
|
41
|
+
sync()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removeChosen(id: string): void {
|
|
45
|
+
if (input.current === null) return
|
|
46
|
+
const next = new DataTransfer()
|
|
47
|
+
for (const entry of chosen) {
|
|
48
|
+
if (entry.id !== id) next.items.add(entry.file)
|
|
49
|
+
}
|
|
50
|
+
input.current.files = next.files
|
|
51
|
+
sync()
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
return (
|
|
19
|
-
<div
|
|
20
|
-
className="flex flex-col gap-1 text-sm"
|
|
21
|
-
onDragOver={(event) => event.preventDefault()}
|
|
22
|
-
onDrop={(event) => {
|
|
23
|
-
event.preventDefault()
|
|
24
|
-
if (input.current === null) return
|
|
25
|
-
const files = new DataTransfer()
|
|
26
|
-
for (const file of event.dataTransfer.files) files.items.add(file)
|
|
27
|
-
input.current.files = files.files
|
|
28
|
-
}}
|
|
29
|
-
>
|
|
55
|
+
<div className="flex flex-col gap-1 text-sm">
|
|
30
56
|
<label htmlFor={ATTACHMENT_FIELD} className="font-medium">
|
|
31
57
|
{fromCopy(copy, 'composer.attachments.label')}
|
|
32
58
|
</label>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
|
|
60
|
+
<div
|
|
61
|
+
className={cn(
|
|
62
|
+
'flex flex-col gap-2 rounded-md border-2 border-dashed p-3 transition-colors',
|
|
63
|
+
dragging ? 'border-ring bg-muted/50' : 'border-input',
|
|
64
|
+
)}
|
|
65
|
+
onDragOver={(event) => {
|
|
66
|
+
event.preventDefault()
|
|
67
|
+
setDragging(true)
|
|
68
|
+
}}
|
|
69
|
+
onDragLeave={() => setDragging(false)}
|
|
70
|
+
onDrop={(event) => {
|
|
71
|
+
event.preventDefault()
|
|
72
|
+
setDragging(false)
|
|
73
|
+
addFiles(event.dataTransfer.files)
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<input
|
|
77
|
+
ref={input}
|
|
78
|
+
id={ATTACHMENT_FIELD}
|
|
79
|
+
type="file"
|
|
80
|
+
name={ATTACHMENT_FIELD}
|
|
81
|
+
multiple
|
|
82
|
+
aria-describedby={`${ATTACHMENT_FIELD}-limits`}
|
|
83
|
+
accept={ACCEPTED_EXTENSIONS.map((extension) => `.${extension}`).join(',')}
|
|
84
|
+
onChange={sync}
|
|
85
|
+
className="rounded-md border border-input bg-card px-3 py-1.5 text-sm file:mr-3 file:rounded file:border-0 file:bg-muted file:px-3 file:py-1 file:text-sm"
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
{chosen.length > 0 && (
|
|
89
|
+
<ul className="flex flex-col gap-1">
|
|
90
|
+
{chosen.map((entry) => (
|
|
91
|
+
<li
|
|
92
|
+
key={entry.id}
|
|
93
|
+
className="flex items-center justify-between gap-2 rounded bg-card px-2 py-1 text-xs"
|
|
94
|
+
>
|
|
95
|
+
<span className="min-w-0 truncate">
|
|
96
|
+
{entry.file.name} · {formatBytes(entry.file.size)}
|
|
97
|
+
</span>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => removeChosen(entry.id)}
|
|
101
|
+
aria-label={formatFromCopy(copy, 'composer.attachments.removeChosen', {
|
|
102
|
+
name: entry.file.name,
|
|
103
|
+
})}
|
|
104
|
+
className="shrink-0 text-muted-foreground hover:text-destructive"
|
|
105
|
+
>
|
|
106
|
+
{fromCopy(copy, 'composer.attachments.remove')}
|
|
107
|
+
</button>
|
|
108
|
+
</li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
43
114
|
<p id={`${ATTACHMENT_FIELD}-limits`} className="text-xs text-muted-foreground">
|
|
44
115
|
{formatFromCopy(copy, 'composer.attachments.hint', {
|
|
45
116
|
count: perPost,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ComboboxKeyEvent {
|
|
2
|
+
readonly key: string
|
|
3
|
+
readonly metaKey: boolean
|
|
4
|
+
readonly ctrlKey: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ComboboxState {
|
|
8
|
+
readonly count: number
|
|
9
|
+
readonly active: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ComboboxKeyAction =
|
|
13
|
+
| { readonly type: 'move'; readonly active: number }
|
|
14
|
+
| { readonly type: 'choose'; readonly index: number }
|
|
15
|
+
| { readonly type: 'dismiss' }
|
|
16
|
+
| null
|
|
17
|
+
|
|
18
|
+
export function comboboxKeyAction(
|
|
19
|
+
event: ComboboxKeyEvent,
|
|
20
|
+
state: ComboboxState,
|
|
21
|
+
): ComboboxKeyAction {
|
|
22
|
+
const { count, active } = state
|
|
23
|
+
if (count <= 0) return null
|
|
24
|
+
|
|
25
|
+
switch (event.key) {
|
|
26
|
+
case 'ArrowDown':
|
|
27
|
+
return { type: 'move', active: (active + 1) % count }
|
|
28
|
+
case 'ArrowUp':
|
|
29
|
+
return { type: 'move', active: (active - 1 + count) % count }
|
|
30
|
+
case 'Escape':
|
|
31
|
+
return { type: 'dismiss' }
|
|
32
|
+
case 'Enter':
|
|
33
|
+
case 'Tab':
|
|
34
|
+
return event.metaKey || event.ctrlKey ? null : { type: 'choose', index: active }
|
|
35
|
+
default:
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -3,18 +3,86 @@
|
|
|
3
3
|
import type { ReactNode } from 'react'
|
|
4
4
|
import { useActionState } from 'react'
|
|
5
5
|
|
|
6
|
+
import type { UploadLimits } from '@meith/attachments/limits'
|
|
7
|
+
|
|
6
8
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
7
9
|
import { deletePostAction, editPostAction, restorePostAction } from '@/server/content-actions'
|
|
10
|
+
import type { EditableAttachment } from '@/view/attachments'
|
|
8
11
|
|
|
9
12
|
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
10
|
-
import { type Copy, fromCopy } from '../shell/copy'
|
|
13
|
+
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
14
|
+
import { AttachmentField } from './attachment-field'
|
|
11
15
|
import { MarkdownEditor } from './markdown-editor'
|
|
12
16
|
|
|
17
|
+
function ExistingAttachments({
|
|
18
|
+
attachments,
|
|
19
|
+
copy,
|
|
20
|
+
}: {
|
|
21
|
+
attachments: readonly EditableAttachment[]
|
|
22
|
+
copy: Copy
|
|
23
|
+
}) {
|
|
24
|
+
if (attachments.length === 0) return null
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<fieldset className="flex flex-col gap-2 text-sm">
|
|
28
|
+
<legend className="font-medium">{fromCopy(copy, 'composer.edit.attachments')}</legend>
|
|
29
|
+
<ul className="flex flex-col gap-2">
|
|
30
|
+
{attachments.map((attachment) => (
|
|
31
|
+
<li
|
|
32
|
+
key={attachment.id}
|
|
33
|
+
className="flex items-center gap-3 rounded-md border border-border bg-card p-2"
|
|
34
|
+
>
|
|
35
|
+
{attachment.thumbnailHref !== null ? (
|
|
36
|
+
<img
|
|
37
|
+
src={attachment.thumbnailHref}
|
|
38
|
+
alt=""
|
|
39
|
+
className="size-10 shrink-0 rounded object-cover"
|
|
40
|
+
/>
|
|
41
|
+
) : (
|
|
42
|
+
<span
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
className="flex size-10 shrink-0 items-center justify-center rounded bg-muted text-sm text-muted-foreground"
|
|
45
|
+
>
|
|
46
|
+
{attachment.status === 'failed' ? '!' : attachment.status === 'pending' ? '…' : '·'}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
51
|
+
<span className="truncate font-medium">{attachment.filename}</span>
|
|
52
|
+
<span className="text-xs text-muted-foreground">
|
|
53
|
+
{attachment.status === 'pending' &&
|
|
54
|
+
fromCopy(copy, 'composer.edit.attachmentProcessing')}
|
|
55
|
+
{attachment.status === 'failed' && fromCopy(copy, 'composer.edit.attachmentFailed')}
|
|
56
|
+
{attachment.status === 'ready' && attachment.size}
|
|
57
|
+
</span>
|
|
58
|
+
</span>
|
|
59
|
+
|
|
60
|
+
<label className="flex shrink-0 items-center gap-1.5 text-xs">
|
|
61
|
+
<input
|
|
62
|
+
type="checkbox"
|
|
63
|
+
name="removeAttachmentIds"
|
|
64
|
+
value={attachment.id}
|
|
65
|
+
aria-label={formatFromCopy(copy, 'composer.edit.removeAttachment', {
|
|
66
|
+
name: attachment.filename,
|
|
67
|
+
})}
|
|
68
|
+
className="size-4"
|
|
69
|
+
/>
|
|
70
|
+
<span aria-hidden="true">{fromCopy(copy, 'composer.edit.remove')}</span>
|
|
71
|
+
</label>
|
|
72
|
+
</li>
|
|
73
|
+
))}
|
|
74
|
+
</ul>
|
|
75
|
+
</fieldset>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
13
79
|
export function EditPostForm({
|
|
14
80
|
threadId,
|
|
15
81
|
postId,
|
|
16
82
|
message,
|
|
17
83
|
reason,
|
|
84
|
+
attachments,
|
|
85
|
+
attachmentLimits,
|
|
18
86
|
toolbar,
|
|
19
87
|
copy,
|
|
20
88
|
}: {
|
|
@@ -22,6 +90,8 @@ export function EditPostForm({
|
|
|
22
90
|
postId: number
|
|
23
91
|
message: string
|
|
24
92
|
reason: string | null
|
|
93
|
+
attachments: readonly EditableAttachment[]
|
|
94
|
+
attachmentLimits: UploadLimits | null
|
|
25
95
|
toolbar?: ReactNode
|
|
26
96
|
copy: Copy
|
|
27
97
|
}) {
|
|
@@ -51,6 +121,10 @@ export function EditPostForm({
|
|
|
51
121
|
/>
|
|
52
122
|
</label>
|
|
53
123
|
|
|
124
|
+
<ExistingAttachments attachments={attachments} copy={copy} />
|
|
125
|
+
|
|
126
|
+
{attachmentLimits !== null && <AttachmentField limits={attachmentLimits} />}
|
|
127
|
+
|
|
54
128
|
<div className="flex flex-wrap gap-3">
|
|
55
129
|
<SubmitButton>{fromCopy(copy, 'composer.edit.submit')}</SubmitButton>
|
|
56
130
|
<PendingButton
|
|
@@ -13,15 +13,13 @@ import {
|
|
|
13
13
|
uploadInlineAttachmentAction,
|
|
14
14
|
} from '@/server/attachment-upload-actions'
|
|
15
15
|
import { type PreviewScope, renderPreviewAction } from '@/server/content-actions'
|
|
16
|
-
import { searchMentionCandidatesAction } from '@/server/mention-actions'
|
|
17
16
|
|
|
18
17
|
import { type Copy, formatFromCopy, fromCopy, useCopy } from '../shell/copy'
|
|
19
18
|
import { attachmentFieldId, MESSAGE_TEXTAREA_ID } from './composer-ids'
|
|
20
19
|
import { listContinuation, pasteAsLink } from './markdown-syntax'
|
|
20
|
+
import { useMentionCombobox } from './mention-combobox'
|
|
21
21
|
import { activeMentionToken, type MentionToken } from './mention-token'
|
|
22
22
|
|
|
23
|
-
const MENTION_DEBOUNCE_MS = 150
|
|
24
|
-
|
|
25
23
|
interface Tool {
|
|
26
24
|
readonly tag: EditorTag
|
|
27
25
|
readonly labelKey: string
|
|
@@ -52,6 +50,12 @@ const TOOLS: readonly Tool[] = [
|
|
|
52
50
|
placeholderKey: 'composer.placeholder.struck',
|
|
53
51
|
},
|
|
54
52
|
{ tag: 'link', labelKey: 'composer.tool.link', glyph: 'Link', shortcut: 'k' },
|
|
53
|
+
{
|
|
54
|
+
tag: 'image',
|
|
55
|
+
labelKey: 'composer.tool.image',
|
|
56
|
+
glyph: 'Image',
|
|
57
|
+
placeholderKey: 'composer.placeholder.image',
|
|
58
|
+
},
|
|
55
59
|
{ tag: 'quote', labelKey: 'composer.tool.quote', glyph: '“”' },
|
|
56
60
|
{ tag: 'code', labelKey: 'composer.tool.code', glyph: '</>' },
|
|
57
61
|
{
|
|
@@ -62,7 +66,14 @@ const TOOLS: readonly Tool[] = [
|
|
|
62
66
|
},
|
|
63
67
|
{ tag: 'bulletedList', labelKey: 'composer.tool.bulletedList', glyph: '•' },
|
|
64
68
|
{ tag: 'numberedList', labelKey: 'composer.tool.numberedList', glyph: '1.' },
|
|
69
|
+
{ tag: 'taskList', labelKey: 'composer.tool.taskList', glyph: '☑' },
|
|
65
70
|
{ tag: 'heading', labelKey: 'composer.tool.heading', glyph: 'H' },
|
|
71
|
+
{
|
|
72
|
+
tag: 'table',
|
|
73
|
+
labelKey: 'composer.tool.table',
|
|
74
|
+
glyph: 'Table',
|
|
75
|
+
placeholderKey: 'composer.placeholder.table',
|
|
76
|
+
},
|
|
66
77
|
]
|
|
67
78
|
|
|
68
79
|
function runTool(field: HTMLTextAreaElement, tool: Tool, copy: Copy): void {
|
|
@@ -122,10 +133,14 @@ export function MarkdownEditor({
|
|
|
122
133
|
|
|
123
134
|
const mentions = scope === 'post'
|
|
124
135
|
const [mentionToken, setMentionToken] = useState<MentionToken | null>(null)
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
|
|
137
|
+
const combobox = useMentionCombobox({
|
|
138
|
+
query: mentionToken?.query ?? null,
|
|
139
|
+
onChoose: (candidate) => {
|
|
140
|
+
if (field.current !== null) applyMention(field.current, candidate)
|
|
141
|
+
},
|
|
142
|
+
onDismiss: () => setMentionToken(null),
|
|
143
|
+
})
|
|
129
144
|
|
|
130
145
|
const fit = useCallback(() => {
|
|
131
146
|
const element = field.current
|
|
@@ -159,23 +174,6 @@ export function MarkdownEditor({
|
|
|
159
174
|
}
|
|
160
175
|
}
|
|
161
176
|
|
|
162
|
-
useEffect(() => {
|
|
163
|
-
if (mentionToken === null) {
|
|
164
|
-
setMentionMatches([])
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const handle = setTimeout(() => {
|
|
169
|
-
void searchMentionCandidatesAction(mentionToken.query).then((matches) => {
|
|
170
|
-
if (latestMentionToken.current !== mentionToken) return
|
|
171
|
-
setMentionMatches(matches)
|
|
172
|
-
setMentionActive(0)
|
|
173
|
-
})
|
|
174
|
-
}, MENTION_DEBOUNCE_MS)
|
|
175
|
-
|
|
176
|
-
return () => clearTimeout(handle)
|
|
177
|
-
}, [mentionToken])
|
|
178
|
-
|
|
179
177
|
function syncMentionToken(element: HTMLTextAreaElement): void {
|
|
180
178
|
if (!mentions) return
|
|
181
179
|
const found =
|
|
@@ -243,28 +241,7 @@ export function MarkdownEditor({
|
|
|
243
241
|
function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
|
244
242
|
const element = event.currentTarget
|
|
245
243
|
|
|
246
|
-
if (
|
|
247
|
-
if (event.key === 'ArrowDown') {
|
|
248
|
-
event.preventDefault()
|
|
249
|
-
setMentionActive((index) => (index + 1) % mentionMatches.length)
|
|
250
|
-
return
|
|
251
|
-
}
|
|
252
|
-
if (event.key === 'ArrowUp') {
|
|
253
|
-
event.preventDefault()
|
|
254
|
-
setMentionActive((index) => (index - 1 + mentionMatches.length) % mentionMatches.length)
|
|
255
|
-
return
|
|
256
|
-
}
|
|
257
|
-
if (event.key === 'Escape') {
|
|
258
|
-
event.preventDefault()
|
|
259
|
-
setMentionToken(null)
|
|
260
|
-
return
|
|
261
|
-
}
|
|
262
|
-
if ((event.key === 'Enter' || event.key === 'Tab') && !event.metaKey && !event.ctrlKey) {
|
|
263
|
-
event.preventDefault()
|
|
264
|
-
applyMention(element, mentionMatches[mentionActive]!)
|
|
265
|
-
return
|
|
266
|
-
}
|
|
267
|
-
}
|
|
244
|
+
if (combobox.handleKeyDown(event)) return
|
|
268
245
|
|
|
269
246
|
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
270
247
|
element.form?.requestSubmit()
|
|
@@ -449,52 +426,15 @@ export function MarkdownEditor({
|
|
|
449
426
|
}}
|
|
450
427
|
onSelect={(event) => syncMentionToken(event.currentTarget)}
|
|
451
428
|
onBlur={() => setMentionToken(null)}
|
|
452
|
-
{...(enhanced
|
|
453
|
-
? {
|
|
454
|
-
role: 'combobox',
|
|
455
|
-
'aria-expanded': true,
|
|
456
|
-
'aria-controls': `${panelId}-mentions`,
|
|
457
|
-
'aria-activedescendant': `${panelId}-mention-${mentionActive}`,
|
|
458
|
-
'aria-autocomplete': 'list' as const,
|
|
459
|
-
}
|
|
460
|
-
: {})}
|
|
429
|
+
{...(enhanced ? combobox.anchorProps : {})}
|
|
461
430
|
className={cn(
|
|
462
431
|
'w-full rounded-md border border-input bg-card px-3 py-2 font-normal text-sm leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
463
432
|
onWriteTab && 'rounded-t-none border-t-0',
|
|
464
433
|
)}
|
|
465
434
|
/>
|
|
466
435
|
|
|
467
|
-
{enhanced &&
|
|
468
|
-
|
|
469
|
-
id={`${panelId}-mentions`}
|
|
470
|
-
role="listbox"
|
|
471
|
-
aria-label={fromCopy(copy, 'composer.mention.suggestions')}
|
|
472
|
-
className="absolute inset-x-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-md border border-border bg-card py-1 text-sm shadow-lg"
|
|
473
|
-
>
|
|
474
|
-
{mentionMatches.map((candidate, index) => (
|
|
475
|
-
<button
|
|
476
|
-
key={candidate.id}
|
|
477
|
-
type="button"
|
|
478
|
-
id={`${panelId}-mention-${index}`}
|
|
479
|
-
role="option"
|
|
480
|
-
aria-selected={index === mentionActive}
|
|
481
|
-
onMouseDown={(event) => event.preventDefault()}
|
|
482
|
-
onClick={() => {
|
|
483
|
-
if (field.current !== null) applyMention(field.current, candidate)
|
|
484
|
-
}}
|
|
485
|
-
onMouseEnter={() => setMentionActive(index)}
|
|
486
|
-
className={cn(
|
|
487
|
-
'block w-full px-3 py-1.5 text-start',
|
|
488
|
-
index === mentionActive
|
|
489
|
-
? 'bg-muted text-foreground'
|
|
490
|
-
: 'text-foreground hover:bg-muted',
|
|
491
|
-
)}
|
|
492
|
-
>
|
|
493
|
-
@{candidate.username}
|
|
494
|
-
</button>
|
|
495
|
-
))}
|
|
496
|
-
</div>
|
|
497
|
-
)}
|
|
436
|
+
{enhanced && combobox.listbox}
|
|
437
|
+
{enhanced && combobox.liveRegion}
|
|
498
438
|
</div>
|
|
499
439
|
</div>
|
|
500
440
|
|
|
@@ -577,8 +517,11 @@ function FormattingHelp({ scope }: { scope: PreviewScope }) {
|
|
|
577
517
|
['```', 'composer.help.codeBlock'],
|
|
578
518
|
['> quoted', 'composer.help.quote'],
|
|
579
519
|
['- item', 'composer.help.list'],
|
|
520
|
+
['- [ ] task', 'composer.help.taskList'],
|
|
580
521
|
['## Heading', 'composer.help.heading'],
|
|
522
|
+
['| a | b |', 'composer.help.table'],
|
|
581
523
|
[':::spoiler', 'composer.help.spoiler'],
|
|
524
|
+
['', 'composer.help.image'],
|
|
582
525
|
['@name', 'composer.help.mention'],
|
|
583
526
|
['[attachment=id]', 'composer.help.attachment'],
|
|
584
527
|
]
|