@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.
Files changed (165) 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/page.tsx +12 -6
  35. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  36. package/app/admin/plugins/page.tsx +3 -6
  37. package/app/admin/users/[id]/page.tsx +5 -11
  38. package/app/admin/users/prune/page.tsx +4 -12
  39. package/app/admin/webhooks/page.tsx +144 -0
  40. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  41. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  42. package/app/atom.xml/route.ts +2 -2
  43. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  44. package/app/feed.xml/route.ts +2 -2
  45. package/app/offline/route.ts +98 -0
  46. package/next.config.mjs +1 -0
  47. package/package.json +49 -48
  48. package/public/sw.js +42 -2
  49. package/src/components/account/draft-forms.tsx +39 -0
  50. package/src/components/account/feed-token.tsx +95 -0
  51. package/src/components/account/notification-forms.tsx +37 -2
  52. package/src/components/account/relation-forms.tsx +3 -2
  53. package/src/components/account/subscription-forms.tsx +20 -6
  54. package/src/components/account/usercp-forms.tsx +44 -0
  55. package/src/components/admin/api-token-forms.tsx +11 -7
  56. package/src/components/admin/ban-filter-forms.tsx +15 -11
  57. package/src/components/admin/content-forms.tsx +31 -14
  58. package/src/components/admin/forum-forms.tsx +241 -129
  59. package/src/components/admin/forum-tree.tsx +5 -7
  60. package/src/components/admin/navigation-forms.tsx +43 -26
  61. package/src/components/admin/navigation-tree.tsx +8 -13
  62. package/src/components/admin/oklch-picker.tsx +2 -1
  63. package/src/components/admin/theme-changes.tsx +3 -2
  64. package/src/components/admin/theme-forms.tsx +3 -2
  65. package/src/components/admin/webhook-forms.tsx +137 -0
  66. package/src/components/auth/register-form.tsx +4 -7
  67. package/src/components/board/live-region.tsx +3 -1
  68. package/src/components/content/attachment-field.tsx +93 -22
  69. package/src/components/content/combobox-keys.ts +38 -0
  70. package/src/components/content/edit-post-form.tsx +75 -1
  71. package/src/components/content/markdown-editor.tsx +29 -86
  72. package/src/components/content/mention-combobox.tsx +137 -0
  73. package/src/components/content/multiquote-button.tsx +15 -12
  74. package/src/components/content/new-thread-form.tsx +95 -17
  75. package/src/components/content/poll-edit-form.tsx +61 -39
  76. package/src/components/content/poll-vote-form.tsx +91 -0
  77. package/src/components/content/poll.tsx +12 -48
  78. package/src/components/content/progressive-marker.tsx +13 -0
  79. package/src/components/content/recipient-segment.ts +39 -0
  80. package/src/components/content/reply-form.tsx +9 -1
  81. package/src/components/content/thanks-button.tsx +12 -7
  82. package/src/components/messages/message-forms.tsx +53 -43
  83. package/src/components/messages/recipient-field.tsx +88 -0
  84. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  85. package/src/components/moderation/modcp-shell.tsx +17 -3
  86. package/src/components/moderation/queue-form.tsx +3 -6
  87. package/src/components/moderation/report-forms.tsx +24 -5
  88. package/src/components/moderation/thread-tools-form.tsx +2 -0
  89. package/src/components/shell/board-notice.tsx +22 -1
  90. package/src/components/shell/confirm-dialog.tsx +93 -0
  91. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  92. package/src/components/shell/notice-toast.tsx +40 -0
  93. package/src/components/shell/notification-menu.tsx +5 -3
  94. package/src/components/shell/page-shell.tsx +3 -0
  95. package/src/components/shell/scheme-toggle.tsx +72 -0
  96. package/src/components/shell/theme-switcher.tsx +16 -36
  97. package/src/server/admin-settings-actions.ts +5 -0
  98. package/src/server/api/search.ts +8 -2
  99. package/src/server/api-token-actions.ts +4 -0
  100. package/src/server/appearance-actions.ts +21 -0
  101. package/src/server/attachments.ts +35 -0
  102. package/src/server/auth-actions.ts +2 -0
  103. package/src/server/auth-form-state.ts +28 -0
  104. package/src/server/auto-watch.ts +31 -0
  105. package/src/server/ban-filter-admin-actions.ts +5 -0
  106. package/src/server/confirm.ts +19 -0
  107. package/src/server/content-actions.ts +129 -19
  108. package/src/server/content-admin-actions.ts +31 -0
  109. package/src/server/discovery.ts +11 -1
  110. package/src/server/feed-routes.ts +13 -4
  111. package/src/server/feed-token-actions.ts +61 -0
  112. package/src/server/feed-token.ts +78 -0
  113. package/src/server/fixture-post-repo.ts +18 -0
  114. package/src/server/forum-admin-actions.ts +23 -16
  115. package/src/server/forum-admin.ts +10 -1
  116. package/src/server/inline-moderation-actions.ts +6 -0
  117. package/src/server/message-actions.ts +9 -0
  118. package/src/server/navigation-admin-actions.ts +6 -0
  119. package/src/server/plugin-admin.ts +7 -2
  120. package/src/server/plugin-pages.ts +50 -0
  121. package/src/server/plugin-panel.ts +34 -1
  122. package/src/server/plugin-routes.ts +8 -0
  123. package/src/server/plugin-view.tsx +46 -7
  124. package/src/server/poll-actions.ts +13 -1
  125. package/src/server/presence.ts +23 -0
  126. package/src/server/reply-core.ts +8 -1
  127. package/src/server/report-actions.ts +13 -2
  128. package/src/server/reputation-actions.ts +12 -1
  129. package/src/server/search-page.ts +8 -2
  130. package/src/server/search.ts +18 -5
  131. package/src/server/subscription-actions.ts +9 -0
  132. package/src/server/thread-core.ts +10 -2
  133. package/src/server/thread-tool-actions.ts +6 -0
  134. package/src/server/unread-goto.ts +28 -0
  135. package/src/server/upgrade-notice.ts +1 -1
  136. package/src/server/usercp-actions.ts +31 -1
  137. package/src/server/viewer-preferences.ts +5 -0
  138. package/src/server/webhook-actions.ts +98 -0
  139. package/src/server/webhooks-admin.ts +122 -0
  140. package/src/theme/contract.fixture.ts +36 -5
  141. package/src/view/account-copy.ts +39 -0
  142. package/src/view/admin-copy.ts +4 -9
  143. package/src/view/admin-nav.ts +6 -0
  144. package/src/view/admin-panel-copy.ts +23 -0
  145. package/src/view/attachments.ts +33 -1
  146. package/src/view/board-index.ts +31 -2
  147. package/src/view/board-title.ts +1 -0
  148. package/src/view/content-copy.ts +22 -12
  149. package/src/view/copy.ts +17 -0
  150. package/src/view/discovery-view.ts +2 -1
  151. package/src/view/drafts.ts +45 -0
  152. package/src/view/editor-toolbar.ts +14 -0
  153. package/src/view/forum-display.ts +39 -7
  154. package/src/view/modcp-nav.ts +32 -0
  155. package/src/view/moderation-copy.ts +20 -0
  156. package/src/view/notifications.ts +1 -0
  157. package/src/view/plugin-panel.ts +13 -0
  158. package/src/view/poll-vote.ts +46 -0
  159. package/src/view/post-link.ts +8 -1
  160. package/src/view/progressive-enhancement.ts +5 -0
  161. package/src/view/shell.ts +2 -1
  162. package/src/view/subscriptions.ts +1 -0
  163. package/src/view/thread-view.ts +41 -2
  164. package/src/view/usercp-nav.ts +6 -0
  165. 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
- <div className="flex flex-col gap-3 py-4">
176
- <FormError message={state.error ?? removeState.error} />
177
- <Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
178
-
179
- <form action={action} className="flex flex-col gap-3" noValidate>
180
- <input type="hidden" name="id" value={item.id} />
181
- <NavigationFields
182
- audiences={audiences}
183
- groups={groups}
184
- parents={parents}
185
- values={item}
186
- copy={copy}
187
- />
188
-
189
- <span className="min-w-28">
190
- <SubmitButton>{fromCopy(copy, 'adminContent.save')}</SubmitButton>
191
- </span>
192
- </form>
193
-
194
- <form action={removeAction}>
195
- <input type="hidden" name="id" value={item.id} />
196
- <PendingButton showWorking className="text-xs text-destructive hover:underline">
197
- {fromCopy(copy, 'adminNavigation.removeThis')}
198
- </PendingButton>
199
- </form>
200
- </div>
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
- <details
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="text-xs font-medium underline decoration-border underline-offset-2 hover:decoration-foreground"
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
- <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