@meith/web 0.30.1 → 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
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '@/server/navigation-admin-actions'
|
|
11
11
|
|
|
12
12
|
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
13
|
+
import { ConfirmDialog } from '../shell/confirm-dialog'
|
|
13
14
|
import { type Copy, fromCopy } from '../shell/copy'
|
|
14
15
|
import { INPUT, Saved } from './form-bits'
|
|
15
16
|
|
|
@@ -161,43 +162,59 @@ export function NavigationItemRowForm({
|
|
|
161
162
|
groups,
|
|
162
163
|
parents,
|
|
163
164
|
copy,
|
|
165
|
+
summary,
|
|
166
|
+
className,
|
|
167
|
+
style,
|
|
164
168
|
}: {
|
|
165
169
|
item: NavigationItemValues
|
|
166
170
|
audiences: readonly AudienceChoice[]
|
|
167
171
|
groups: readonly GroupChoice[]
|
|
168
172
|
parents: readonly ParentChoice[]
|
|
169
173
|
copy: Copy
|
|
174
|
+
summary: React.ReactNode
|
|
175
|
+
className?: string
|
|
176
|
+
style?: React.CSSProperties
|
|
170
177
|
}) {
|
|
171
178
|
const [state, action] = useActionState(updateNavigationItemAction, EMPTY_STATE)
|
|
172
179
|
const [removeState, removeAction] = useActionState(deleteNavigationItemAction, EMPTY_STATE)
|
|
173
180
|
|
|
174
181
|
return (
|
|
175
|
-
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
182
|
+
<>
|
|
183
|
+
<details className={className} style={style}>
|
|
184
|
+
<summary className="cursor-pointer py-2 text-xs font-medium text-muted-foreground hover:text-foreground">
|
|
185
|
+
{summary}
|
|
186
|
+
</summary>
|
|
187
|
+
|
|
188
|
+
<div className="flex flex-col gap-3 py-4">
|
|
189
|
+
<FormError message={state.error ?? removeState.error} />
|
|
190
|
+
<Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
|
|
191
|
+
|
|
192
|
+
<form action={action} className="flex flex-col gap-3" noValidate>
|
|
193
|
+
<input type="hidden" name="id" value={item.id} />
|
|
194
|
+
<NavigationFields
|
|
195
|
+
audiences={audiences}
|
|
196
|
+
groups={groups}
|
|
197
|
+
parents={parents}
|
|
198
|
+
values={item}
|
|
199
|
+
copy={copy}
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<span className="min-w-28">
|
|
203
|
+
<SubmitButton>{fromCopy(copy, 'adminContent.save')}</SubmitButton>
|
|
204
|
+
</span>
|
|
205
|
+
</form>
|
|
206
|
+
|
|
207
|
+
<form action={removeAction}>
|
|
208
|
+
<input type="hidden" name="id" value={item.id} />
|
|
209
|
+
<PendingButton showWorking className="text-xs text-destructive hover:underline">
|
|
210
|
+
{fromCopy(copy, 'adminNavigation.removeThis')}
|
|
211
|
+
</PendingButton>
|
|
212
|
+
</form>
|
|
213
|
+
</div>
|
|
214
|
+
</details>
|
|
215
|
+
|
|
216
|
+
<ConfirmDialog confirm={removeState.confirm} action={removeAction} />
|
|
217
|
+
</>
|
|
201
218
|
)
|
|
202
219
|
}
|
|
203
220
|
|
|
@@ -404,21 +404,16 @@ export function NavigationTree({
|
|
|
404
404
|
</form>
|
|
405
405
|
</div>
|
|
406
406
|
|
|
407
|
-
<
|
|
407
|
+
<NavigationItemRowForm
|
|
408
|
+
item={row}
|
|
409
|
+
audiences={audiences}
|
|
410
|
+
groups={groups}
|
|
411
|
+
parents={parents}
|
|
412
|
+
copy={copy}
|
|
413
|
+
summary={formatFromCopy(copy, 'adminNavigation.tree.edit', { name: row.name })}
|
|
408
414
|
className="rounded-md border border-border bg-card px-3"
|
|
409
415
|
style={{ marginLeft: `${row.depth * INDENT_PX}px` }}
|
|
410
|
-
|
|
411
|
-
<summary className="cursor-pointer py-2 text-xs font-medium text-muted-foreground hover:text-foreground">
|
|
412
|
-
{formatFromCopy(copy, 'adminNavigation.tree.edit', { name: row.name })}
|
|
413
|
-
</summary>
|
|
414
|
-
<NavigationItemRowForm
|
|
415
|
-
item={row}
|
|
416
|
-
audiences={audiences}
|
|
417
|
-
groups={groups}
|
|
418
|
-
parents={parents}
|
|
419
|
-
copy={copy}
|
|
420
|
-
/>
|
|
421
|
-
</details>
|
|
416
|
+
/>
|
|
422
417
|
</li>
|
|
423
418
|
)
|
|
424
419
|
})}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
parseColour,
|
|
11
11
|
rgbToHex,
|
|
12
12
|
} from '@meith/theme-kit'
|
|
13
|
+
import { textLinkVariants } from '@meith/ui'
|
|
13
14
|
|
|
14
15
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
15
16
|
|
|
@@ -167,7 +168,7 @@ export function OklchPicker({
|
|
|
167
168
|
<button
|
|
168
169
|
type="button"
|
|
169
170
|
onClick={() => onChange('')}
|
|
170
|
-
className=
|
|
171
|
+
className={textLinkVariants({ tone: 'inherit', size: 'xs' })}
|
|
171
172
|
>
|
|
172
173
|
{fromCopy(copy, 'adminTheme.oklch.clear')}
|
|
173
174
|
</button>
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { textLinkVariants } from '@meith/ui'
|
|
4
|
+
|
|
3
5
|
import { type ContrastCheck, checkContrast, contrastGrade, formatRatio } from '@/view/contrast'
|
|
4
6
|
import {
|
|
5
7
|
changeCounts,
|
|
@@ -14,8 +16,7 @@ import {
|
|
|
14
16
|
|
|
15
17
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
16
18
|
|
|
17
|
-
const LINK =
|
|
18
|
-
'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'
|
|
19
|
+
const LINK = `${textLinkVariants({ tone: 'inherit', size: 'xs' })} focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`
|
|
19
20
|
|
|
20
21
|
function StateBadge({ change, copy }: { change: TokenChange; copy: Copy }) {
|
|
21
22
|
const [label, tone] =
|
|
@@ -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
|