@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.
Files changed (174) hide show
  1. package/app/(board)/[slug]/feed.xml/route.ts +5 -3
  2. package/app/(board)/[slug]/new/page.tsx +5 -0
  3. package/app/(board)/[slug]/page.tsx +7 -1
  4. package/app/(board)/discover/[view]/page.tsx +6 -1
  5. package/app/(board)/member/[id]/page.tsx +3 -5
  6. package/app/(board)/member/[id]/reputation/page.tsx +8 -15
  7. package/app/(board)/members/page.tsx +3 -8
  8. package/app/(board)/messages/[id]/page.tsx +3 -7
  9. package/app/(board)/messages/page.tsx +3 -5
  10. package/app/(board)/modcp/ip/page.tsx +3 -5
  11. package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
  12. package/app/(board)/moderation/reports/page.tsx +53 -15
  13. package/app/(board)/moderation/warn/page.tsx +3 -6
  14. package/app/(board)/notifications/page.tsx +5 -10
  15. package/app/(board)/notifications/preferences/page.tsx +8 -0
  16. package/app/(board)/online/page.tsx +12 -6
  17. package/app/(board)/staff/page.tsx +4 -5
  18. package/app/(board)/stats/page.tsx +10 -3
  19. package/app/(board)/subscriptions/page.tsx +5 -10
  20. package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
  21. package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
  22. package/app/(board)/thread/[slug]/page.tsx +49 -11
  23. package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
  24. package/app/(board)/unsubscribe/page.tsx +8 -11
  25. package/app/(board)/usercp/contacts/page.tsx +3 -5
  26. package/app/(board)/usercp/drafts/page.tsx +70 -0
  27. package/app/(board)/usercp/options/page.tsx +8 -1
  28. package/app/(board)/usercp/security/page.tsx +16 -0
  29. package/app/admin/antispam/page.tsx +4 -12
  30. package/app/admin/content/attachments/page.tsx +3 -6
  31. package/app/admin/content/page.tsx +3 -13
  32. package/app/admin/forums/[id]/permissions/page.tsx +9 -12
  33. package/app/admin/groups/page.tsx +7 -9
  34. package/app/admin/layout.tsx +23 -8
  35. package/app/admin/page.tsx +12 -6
  36. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  37. package/app/admin/plugins/page.tsx +3 -6
  38. package/app/admin/users/[id]/page.tsx +5 -11
  39. package/app/admin/users/prune/page.tsx +4 -12
  40. package/app/admin/webhooks/page.tsx +144 -0
  41. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  42. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  43. package/app/atom.xml/route.ts +2 -2
  44. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  45. package/app/feed.xml/route.ts +2 -2
  46. package/app/offline/route.ts +98 -0
  47. package/next.config.mjs +1 -0
  48. package/package.json +49 -48
  49. package/public/sw.js +42 -2
  50. package/src/components/account/draft-forms.tsx +39 -0
  51. package/src/components/account/feed-token.tsx +95 -0
  52. package/src/components/account/notification-forms.tsx +37 -2
  53. package/src/components/account/relation-forms.tsx +3 -2
  54. package/src/components/account/subscription-forms.tsx +20 -6
  55. package/src/components/account/usercp-forms.tsx +44 -0
  56. package/src/components/admin/admin-forms.tsx +57 -9
  57. package/src/components/admin/api-token-forms.tsx +11 -7
  58. package/src/components/admin/ban-filter-forms.tsx +15 -11
  59. package/src/components/admin/content-forms.tsx +31 -14
  60. package/src/components/admin/forum-forms.tsx +241 -129
  61. package/src/components/admin/forum-tree.tsx +5 -7
  62. package/src/components/admin/navigation-forms.tsx +43 -26
  63. package/src/components/admin/navigation-tree.tsx +8 -13
  64. package/src/components/admin/oklch-picker.tsx +2 -1
  65. package/src/components/admin/theme-changes.tsx +3 -2
  66. package/src/components/admin/theme-forms.tsx +3 -2
  67. package/src/components/admin/webhook-forms.tsx +137 -0
  68. package/src/components/auth/register-form.tsx +4 -7
  69. package/src/components/board/live-region.tsx +3 -1
  70. package/src/components/content/attachment-field.tsx +93 -22
  71. package/src/components/content/combobox-keys.ts +38 -0
  72. package/src/components/content/edit-post-form.tsx +75 -1
  73. package/src/components/content/markdown-editor.tsx +29 -86
  74. package/src/components/content/mention-combobox.tsx +137 -0
  75. package/src/components/content/multiquote-button.tsx +15 -12
  76. package/src/components/content/new-thread-form.tsx +95 -17
  77. package/src/components/content/poll-edit-form.tsx +61 -39
  78. package/src/components/content/poll-vote-form.tsx +91 -0
  79. package/src/components/content/poll.tsx +12 -48
  80. package/src/components/content/progressive-marker.tsx +13 -0
  81. package/src/components/content/recipient-segment.ts +39 -0
  82. package/src/components/content/reply-form.tsx +9 -1
  83. package/src/components/content/thanks-button.tsx +12 -7
  84. package/src/components/messages/message-forms.tsx +53 -43
  85. package/src/components/messages/recipient-field.tsx +88 -0
  86. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  87. package/src/components/moderation/modcp-shell.tsx +17 -3
  88. package/src/components/moderation/queue-form.tsx +3 -6
  89. package/src/components/moderation/report-forms.tsx +24 -5
  90. package/src/components/moderation/thread-tools-form.tsx +2 -0
  91. package/src/components/shell/board-notice.tsx +22 -1
  92. package/src/components/shell/confirm-dialog.tsx +93 -0
  93. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  94. package/src/components/shell/notice-toast.tsx +40 -0
  95. package/src/components/shell/notification-menu.tsx +5 -3
  96. package/src/components/shell/page-shell.tsx +3 -0
  97. package/src/components/shell/scheme-toggle.tsx +72 -0
  98. package/src/components/shell/theme-switcher.tsx +16 -36
  99. package/src/components/shell/view-tabs.tsx +1 -1
  100. package/src/server/admin-actions.ts +118 -49
  101. package/src/server/admin-settings-actions.ts +5 -0
  102. package/src/server/admin.ts +70 -2
  103. package/src/server/api/search.ts +8 -2
  104. package/src/server/api-token-actions.ts +4 -0
  105. package/src/server/appearance-actions.ts +21 -0
  106. package/src/server/attachments.ts +35 -0
  107. package/src/server/auth-actions.ts +2 -0
  108. package/src/server/auth-form-state.ts +28 -0
  109. package/src/server/auto-watch.ts +31 -0
  110. package/src/server/ban-filter-admin-actions.ts +5 -0
  111. package/src/server/confirm.ts +19 -0
  112. package/src/server/content-actions.ts +129 -19
  113. package/src/server/content-admin-actions.ts +31 -0
  114. package/src/server/cookies.ts +18 -0
  115. package/src/server/discovery.ts +11 -1
  116. package/src/server/feed-routes.ts +13 -4
  117. package/src/server/feed-token-actions.ts +61 -0
  118. package/src/server/feed-token.ts +78 -0
  119. package/src/server/fixture-post-repo.ts +18 -0
  120. package/src/server/forum-admin-actions.ts +23 -16
  121. package/src/server/forum-admin.ts +10 -1
  122. package/src/server/inline-moderation-actions.ts +6 -0
  123. package/src/server/message-actions.ts +9 -0
  124. package/src/server/navigation-admin-actions.ts +6 -0
  125. package/src/server/plugin-admin.ts +7 -2
  126. package/src/server/plugin-pages.ts +50 -0
  127. package/src/server/plugin-panel.ts +34 -1
  128. package/src/server/plugin-routes.ts +8 -0
  129. package/src/server/plugin-view.tsx +46 -7
  130. package/src/server/poll-actions.ts +13 -1
  131. package/src/server/presence.ts +23 -0
  132. package/src/server/push-actions.ts +9 -0
  133. package/src/server/reply-core.ts +8 -1
  134. package/src/server/report-actions.ts +13 -2
  135. package/src/server/reputation-actions.ts +12 -1
  136. package/src/server/search-page.ts +8 -2
  137. package/src/server/search.ts +18 -5
  138. package/src/server/session-cookies.ts +18 -0
  139. package/src/server/subscription-actions.ts +9 -0
  140. package/src/server/thread-core.ts +10 -2
  141. package/src/server/thread-tool-actions.ts +6 -0
  142. package/src/server/unread-goto.ts +28 -0
  143. package/src/server/upgrade-notice.ts +1 -1
  144. package/src/server/usercp-actions.ts +31 -1
  145. package/src/server/viewer-preferences.ts +5 -0
  146. package/src/server/webhook-actions.ts +98 -0
  147. package/src/server/webhooks-admin.ts +122 -0
  148. package/src/styles/globals.css +1 -1
  149. package/src/theme/contract.fixture.ts +36 -5
  150. package/src/view/account-copy.ts +39 -0
  151. package/src/view/admin-copy.ts +4 -9
  152. package/src/view/admin-nav.ts +6 -0
  153. package/src/view/admin-panel-copy.ts +29 -1
  154. package/src/view/attachments.ts +33 -1
  155. package/src/view/board-index.ts +31 -2
  156. package/src/view/board-title.ts +1 -0
  157. package/src/view/content-copy.ts +22 -12
  158. package/src/view/copy.ts +17 -0
  159. package/src/view/discovery-view.ts +2 -1
  160. package/src/view/drafts.ts +45 -0
  161. package/src/view/editor-toolbar.ts +14 -0
  162. package/src/view/forum-display.ts +39 -7
  163. package/src/view/modcp-nav.ts +32 -0
  164. package/src/view/moderation-copy.ts +20 -0
  165. package/src/view/notifications.ts +1 -0
  166. package/src/view/plugin-panel.ts +13 -0
  167. package/src/view/poll-vote.ts +46 -0
  168. package/src/view/post-link.ts +8 -1
  169. package/src/view/progressive-enhancement.ts +5 -0
  170. package/src/view/shell.ts +2 -1
  171. package/src/view/subscriptions.ts +1 -0
  172. package/src/view/thread-view.ts +41 -2
  173. package/src/view/usercp-nav.ts +6 -0
  174. 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
- <a
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
- </a>
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="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
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
- <input
34
- ref={input}
35
- id={ATTACHMENT_FIELD}
36
- type="file"
37
- name={ATTACHMENT_FIELD}
38
- multiple
39
- aria-describedby={`${ATTACHMENT_FIELD}-limits`}
40
- accept={ACCEPTED_EXTENSIONS.map((extension) => `.${extension}`).join(',')}
41
- 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"
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
- const [mentionMatches, setMentionMatches] = useState<readonly MemberSuggestion[]>([])
126
- const [mentionActive, setMentionActive] = useState(0)
127
- const latestMentionToken = useRef(mentionToken)
128
- latestMentionToken.current = mentionToken
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 (mentionToken !== null && mentionMatches.length > 0) {
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 && mentionToken !== null && mentionMatches.length > 0
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 && mentionToken !== null && mentionMatches.length > 0 && (
468
- <div
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
+ ['![alt](url)', 'composer.help.image'],
582
525
  ['@name', 'composer.help.mention'],
583
526
  ['[attachment=id]', 'composer.help.attachment'],
584
527
  ]