@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
@@ -147,10 +147,23 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
147
147
  boardTitle: 'The Bike Shed',
148
148
  homeHref: '/',
149
149
  viewer: VIEWER,
150
- navigation: [{ label: 'Unanswered', href: '/discover/unanswered' }],
150
+ navigation: [
151
+ { label: 'Unanswered', href: '/discover/unanswered' },
152
+ {
153
+ label: 'Community',
154
+ href: '/community',
155
+ submenu: [{ label: 'Rules', href: '/community/rules' }],
156
+ },
157
+ ],
151
158
  children: region('user-panel'),
152
159
  },
153
- requires: ['The Bike Shed', region('user-panel'), '/discover/unanswered'],
160
+ requires: [
161
+ 'The Bike Shed',
162
+ region('user-panel'),
163
+ '/discover/unanswered',
164
+ '/community/rules',
165
+ 'Rules',
166
+ ],
154
167
  },
155
168
 
156
169
  UserPanel: {
@@ -331,8 +344,18 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
331
344
  },
332
345
 
333
346
  ThreadRow: {
334
- model: { thread: { ...THREAD, visibility: 'deleted' }, select: null },
335
- requires: ['Bikeshedding the bike shed', '/thread/91-bikeshedding', '27', 'data-visibility'],
347
+ model: {
348
+ thread: { ...THREAD, visibility: 'deleted' },
349
+ select: null,
350
+ regions: { pluginBadges: pluginRegion('threadrow-badges') },
351
+ },
352
+ requires: [
353
+ 'Bikeshedding the bike shed',
354
+ '/thread/91-bikeshedding',
355
+ '27',
356
+ 'data-visibility',
357
+ pluginRegion('threadrow-badges'),
358
+ ],
336
359
  },
337
360
 
338
361
  SubforumList: {
@@ -351,13 +374,19 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
351
374
  forum: { label: 'General discussion', href: '/f/3-general' },
352
375
  replyHref: '/thread/91-bikeshedding/reply',
353
376
  markReadAction: '/thread/91-bikeshedding/mark-read',
377
+ watch: { subscribed: true, action: '/thread/91-bikeshedding/unwatch' },
354
378
  regions: {
355
379
  posts: region('posts'),
356
380
  pagination: region('pagination'),
357
381
  quickReply: region('quick-reply'),
358
382
  },
359
383
  },
360
- requires: ['Bikeshedding the bike shed', region('posts'), region('pagination')],
384
+ requires: [
385
+ 'Bikeshedding the bike shed',
386
+ '/thread/91-bikeshedding/unwatch',
387
+ region('posts'),
388
+ region('pagination'),
389
+ ],
361
390
  },
362
391
 
363
392
  PostBit: {
@@ -417,6 +446,7 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
417
446
  buttons: [
418
447
  {
419
448
  tag: 'bold',
449
+ insertion: null,
420
450
  label: 'Bold',
421
451
  title: 'Bold (Ctrl+B)',
422
452
  keyShortcut: 'Control+b',
@@ -425,6 +455,7 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
425
455
  },
426
456
  {
427
457
  tag: 'link',
458
+ insertion: null,
428
459
  label: 'Link',
429
460
  title: 'Link',
430
461
  keyShortcut: null,
@@ -48,6 +48,10 @@ export function optionsFormCopy(
48
48
  'accountForm.options.pageSizeHint',
49
49
  'accountForm.options.invisible',
50
50
  'accountForm.options.invisibleHint',
51
+ 'accountForm.options.autoWatchOwnThreads',
52
+ 'accountForm.options.autoWatchOwnThreadsHint',
53
+ 'accountForm.options.autoWatchRepliedThreads',
54
+ 'accountForm.options.autoWatchRepliedThreadsHint',
51
55
  'accountForm.options.submit',
52
56
  ],
53
57
  t,
@@ -286,6 +290,36 @@ export function activityDetailLabel(
286
290
  return t.t('accountForm.activity.detail', { at, device, address })
287
291
  }
288
292
 
293
+ export function feedTokenCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
294
+ return copyFor(
295
+ [
296
+ 'accountForm.feed.title',
297
+ 'accountForm.feed.blurb',
298
+ 'accountForm.feed.none',
299
+ 'accountForm.feed.active',
300
+ 'accountForm.feed.generate',
301
+ 'accountForm.feed.regenerate',
302
+ 'accountForm.feed.revoke',
303
+ 'accountForm.feed.copyNow',
304
+ 'accountForm.feed.revealHint',
305
+ 'accountForm.feed.rssLabel',
306
+ 'accountForm.feed.atomLabel',
307
+ 'accountForm.feed.warning',
308
+ 'form.working',
309
+ ],
310
+ t,
311
+ )
312
+ }
313
+
314
+ export function feedTokenDetailLabel(
315
+ created: string,
316
+ lastUsed: string | null,
317
+ t: Translator = untranslated(),
318
+ ): string {
319
+ const used = lastUsed === null ? t.t('accountForm.feed.neverUsed') : lastUsed
320
+ return t.t('accountForm.feed.detail', { created, used })
321
+ }
322
+
289
323
  export function notificationFormsCopy(
290
324
  t: Translator = untranslated(),
291
325
  ): Readonly<Record<string, string>> {
@@ -304,6 +338,11 @@ export function notificationFormsCopy(
304
338
  'accountForm.announcements.description',
305
339
  'accountForm.announcements.blurb',
306
340
  'accountForm.announcements.submit',
341
+ 'accountForm.boardDigest.legend',
342
+ 'accountForm.boardDigest.description',
343
+ 'accountForm.boardDigest.weekly',
344
+ 'accountForm.boardDigest.monthly',
345
+ 'accountForm.boardDigest.submit',
307
346
  'accountForm.push.heading',
308
347
  'accountForm.push.subscribe',
309
348
  'accountForm.push.unsubscribe',
@@ -61,8 +61,10 @@ export function forumAdminCopy(t: Translator = untranslated()): Readonly<Record<
61
61
  'adminForum.permGroup.moderation',
62
62
  'adminForum.permGroup.attachments',
63
63
  'adminForum.permGroup.approvals',
64
+ 'adminForum.permGroup.numeric',
64
65
  'adminForum.permGroup.other',
65
- 'adminForum.inheritsAll',
66
+ 'adminForum.permission',
67
+ 'adminForum.saveMatrix',
66
68
  'adminForum.unsavedChanges',
67
69
  'adminForum.copied',
68
70
  'adminForum.copyNote',
@@ -102,14 +104,7 @@ export function forumAdminCopy(t: Translator = untranslated()): Readonly<Record<
102
104
  t,
103
105
  ),
104
106
  ...patternCopy(
105
- [
106
- 'adminForum.perm.setHere',
107
- 'adminForum.perm.inheritedDefault',
108
- 'adminForum.perm.inheritedFrom',
109
- 'adminForum.saveGroup',
110
- 'adminForum.copyTo',
111
- 'adminForum.overridesSet',
112
- ],
107
+ ['adminForum.perm.inheritedDefault', 'adminForum.perm.inheritedFrom', 'adminForum.copyTo'],
113
108
  t,
114
109
  ),
115
110
  }
@@ -104,6 +104,12 @@ export const ADMIN_SECTIONS: PanelNav = [
104
104
  icon: 'tokens',
105
105
  blurbKey: 'adminNav.admin-api-tokens.blurb',
106
106
  },
107
+ {
108
+ href: '/admin/webhooks',
109
+ titleKey: 'adminNav.admin-webhooks.title',
110
+ icon: 'subscriptions',
111
+ blurbKey: 'adminNav.admin-webhooks.blurb',
112
+ },
107
113
  {
108
114
  href: '/admin/system',
109
115
  titleKey: 'adminNav.admin-system.title',
@@ -15,7 +15,12 @@ export function adminFormsCopy(t: Translator = untranslated()): Readonly<Record<
15
15
  'adminPanel.reason.default',
16
16
  'adminPanel.password',
17
17
  'adminPanel.twoFactorCode',
18
- 'adminPanel.twoFactorCodeHint',
18
+ 'adminPanel.twoFactor.title',
19
+ 'adminPanel.twoFactor.lede',
20
+ 'adminPanel.twoFactor.recoveryHint',
21
+ 'adminPanel.twoFactor.recoveryHintExhausted',
22
+ 'adminPanel.twoFactor.submit',
23
+ 'adminPanel.twoFactor.cancel',
19
24
  'adminPanel.enter',
20
25
  'adminPanel.leave',
21
26
  ],
@@ -47,6 +52,29 @@ export function apiTokenFormsCopy(
47
52
  )
48
53
  }
49
54
 
55
+ export function webhookFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
56
+ return copyFor(
57
+ [
58
+ 'adminPanel.webhook.copyNow',
59
+ 'adminPanel.webhook.url',
60
+ 'adminPanel.webhook.urlPlaceholder',
61
+ 'adminPanel.webhook.urlHint',
62
+ 'adminPanel.webhook.topics',
63
+ 'adminPanel.webhook.topicsHint',
64
+ 'adminPanel.webhook.format',
65
+ 'adminPanel.webhook.formatHint',
66
+ 'adminPanel.webhook.format.json',
67
+ 'adminPanel.webhook.format.discord',
68
+ 'adminPanel.webhook.active',
69
+ 'adminPanel.webhook.create',
70
+ 'adminPanel.webhook.enable',
71
+ 'adminPanel.webhook.disable',
72
+ 'adminPanel.webhook.delete',
73
+ ],
74
+ t,
75
+ )
76
+ }
77
+
50
78
  export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
51
79
  return {
52
80
  ...copyFor(
@@ -1,4 +1,8 @@
1
- import { type AttachmentRecord, attachmentType } from '@meith/attachments/types'
1
+ import {
2
+ type AttachmentRecord,
3
+ type AttachmentStatus,
4
+ attachmentType,
5
+ } from '@meith/attachments/types'
2
6
  import type { PostAttachmentModel } from '@meith/theme-kit'
3
7
 
4
8
  export function formatBytes(bytes: number): string {
@@ -33,6 +37,34 @@ export function attachmentModel(record: AttachmentRecord): PostAttachmentModel |
33
37
  }
34
38
  }
35
39
 
40
+ export interface EditableAttachment {
41
+ readonly id: number
42
+ readonly filename: string
43
+ readonly size: string
44
+ readonly status: AttachmentStatus
45
+ readonly isImage: boolean
46
+ readonly href: string | null
47
+ readonly thumbnailHref: string | null
48
+ }
49
+
50
+ export function editableAttachment(record: AttachmentRecord): EditableAttachment {
51
+ const ready = record.status === 'ready' && record.storageKey !== null
52
+ const type = attachmentType(record.contentType)
53
+
54
+ return {
55
+ id: record.id,
56
+ filename: record.filename,
57
+ size: formatBytes(record.sizeBytes),
58
+ status: record.status,
59
+ isImage: ready && type?.inline === true,
60
+ href: ready ? attachmentHref(record.id) : null,
61
+ thumbnailHref:
62
+ ready && type?.inline === true && record.thumbnailKey !== null
63
+ ? thumbnailHref(record.id)
64
+ : null,
65
+ }
66
+ }
67
+
36
68
  export function attachmentsByPost(
37
69
  records: readonly AttachmentRecord[],
38
70
  ): ReadonlyMap<number, readonly PostAttachmentModel[]> {
@@ -64,6 +64,26 @@ function toLastPost(
64
64
  }
65
65
  }
66
66
 
67
+ export interface ForumRowHydration {
68
+ readonly lastPost: LastPostModel | null
69
+ readonly isUnread: boolean
70
+ }
71
+
72
+ export function hydrateForumRow(
73
+ row: ForumListingRow,
74
+ ownThreadsOnly: boolean,
75
+ unreadForumIds: ReadonlySet<number> | undefined,
76
+ now: Date,
77
+ t: Translator | undefined,
78
+ identities: ReadonlyMap<number, MemberIdentity> | undefined,
79
+ wordFilter: CompiledWordFilter | undefined,
80
+ ): ForumRowHydration {
81
+ return {
82
+ lastPost: ownThreadsOnly ? null : toLastPost(row, now, t, identities, wordFilter),
83
+ isUnread: !ownThreadsOnly && (unreadForumIds?.has(row.id) ?? false),
84
+ }
85
+ }
86
+
67
87
  function toForumRow(
68
88
  node: ForumNode<ForumListingRow>,
69
89
  now: Date,
@@ -74,6 +94,15 @@ function toForumRow(
74
94
  wordFilter: CompiledWordFilter | undefined,
75
95
  ): ForumRowModel {
76
96
  const ownThreadsOnly = ownThreadsOnlyForumIds?.has(node.id) ?? false
97
+ const hydration = hydrateForumRow(
98
+ node,
99
+ ownThreadsOnly,
100
+ unreadForumIds,
101
+ now,
102
+ t,
103
+ identities,
104
+ wordFilter,
105
+ )
77
106
 
78
107
  return {
79
108
  id: node.id,
@@ -83,8 +112,8 @@ function toForumRow(
83
112
  type: node.type,
84
113
  threadCount: count(ownThreadsOnly ? 0 : node.threadCount, t),
85
114
  postCount: count(ownThreadsOnly ? 0 : node.postCount, t),
86
- lastPost: ownThreadsOnly ? null : toLastPost(node, now, t, identities, wordFilter),
87
- isUnread: !ownThreadsOnly && (unreadForumIds?.has(node.id) ?? false),
115
+ lastPost: hydration.lastPost,
116
+ isUnread: hydration.isUnread,
88
117
  subforums: node.children.map(
89
118
  (child): LinkModel => ({ label: child.title, href: hrefFor(child) }),
90
119
  ),
@@ -0,0 +1 @@
1
+ export const BOARD_TITLE = 'Meith'
@@ -28,18 +28,25 @@ export function replyFormCopy(t: Translator = untranslated()): Readonly<Record<s
28
28
  }
29
29
 
30
30
  export function editPostFormCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
31
- return copyFor(
32
- [
33
- 'composer.edit.reason',
34
- 'composer.edit.submit',
35
- 'composer.edit.delete',
36
- 'composer.edit.deleteBlurb',
37
- 'composer.edit.restore',
38
- 'composer.edit.restoreBlurb',
39
- 'composer.preview',
40
- ],
41
- t,
42
- )
31
+ return {
32
+ ...copyFor(
33
+ [
34
+ 'composer.edit.reason',
35
+ 'composer.edit.submit',
36
+ 'composer.edit.delete',
37
+ 'composer.edit.deleteBlurb',
38
+ 'composer.edit.restore',
39
+ 'composer.edit.restoreBlurb',
40
+ 'composer.edit.attachments',
41
+ 'composer.edit.remove',
42
+ 'composer.edit.attachmentProcessing',
43
+ 'composer.edit.attachmentFailed',
44
+ 'composer.preview',
45
+ ],
46
+ t,
47
+ ),
48
+ ...patternCopy(['composer.edit.removeAttachment'], t),
49
+ }
43
50
  }
44
51
 
45
52
  export function newThreadFormCopy(
@@ -63,7 +70,10 @@ export function newThreadFormCopy(
63
70
  'composer.newThread.pollChoicesHint',
64
71
  'composer.newThread.pollRevote',
65
72
  'composer.newThread.pollPublic',
73
+ 'composer.newThread.morePollOptions',
66
74
  'composer.newThread.submit',
75
+ 'pollEdit.closesAt',
76
+ 'pollEdit.closesAtHint',
67
77
  ],
68
78
  t,
69
79
  ),
package/src/view/copy.ts CHANGED
@@ -23,6 +23,10 @@ export function patternCopy(
23
23
  const CHROME_KEYS = [
24
24
  'form.working',
25
25
  'form.notSaved',
26
+ 'confirm.confirm',
27
+ 'confirm.cancel',
28
+ 'confirm.heading',
29
+ 'notice.dismiss',
26
30
  'composer.message',
27
31
  'composer.tabs',
28
32
  'composer.write',
@@ -36,37 +40,48 @@ const CHROME_KEYS = [
36
40
  'composer.tool.italic',
37
41
  'composer.tool.strikethrough',
38
42
  'composer.tool.link',
43
+ 'composer.tool.image',
39
44
  'composer.tool.quote',
40
45
  'composer.tool.code',
41
46
  'composer.tool.bulletedList',
42
47
  'composer.tool.numberedList',
48
+ 'composer.tool.taskList',
43
49
  'composer.tool.heading',
50
+ 'composer.tool.table',
44
51
  'composer.tool.spoiler',
45
52
  'composer.tool.attachment',
46
53
  'composer.placeholder.bold',
47
54
  'composer.placeholder.italic',
48
55
  'composer.placeholder.struck',
49
56
  'composer.placeholder.spoiler',
57
+ 'composer.placeholder.image',
58
+ 'composer.placeholder.table',
50
59
  'composer.help.summary',
51
60
  'composer.help.bold',
52
61
  'composer.help.italic',
53
62
  'composer.help.struck',
54
63
  'composer.help.link',
64
+ 'composer.help.image',
55
65
  'composer.help.codeInline',
56
66
  'composer.help.codeBlock',
57
67
  'composer.help.quote',
58
68
  'composer.help.list',
69
+ 'composer.help.taskList',
59
70
  'composer.help.heading',
71
+ 'composer.help.table',
60
72
  'composer.help.note',
61
73
  'composer.help.spoiler',
62
74
  'composer.help.mention',
63
75
  'composer.help.attachment',
64
76
  'composer.attachments.label',
77
+ 'composer.attachments.remove',
65
78
  'composer.mention.suggestions',
79
+ 'composer.recipient.suggestions',
66
80
  'composer.attachment.uploading',
67
81
  'composer.thanks.thanked',
68
82
  'composer.thanks.thanks',
69
83
  'composer.multiquote.button',
84
+ 'composer.multiquote.tooltip',
70
85
  'composer.multiquote.addToReply',
71
86
  'composer.multiquote.adding',
72
87
  'composer.multiquote.clear',
@@ -75,8 +90,10 @@ const CHROME_KEYS = [
75
90
  ] as const
76
91
 
77
92
  const CHROME_PATTERN_KEYS = [
93
+ 'composer.mention.available',
78
94
  'composer.toolShortcut',
79
95
  'composer.attachments.hint',
96
+ 'composer.attachments.removeChosen',
80
97
  'composer.thanks.count',
81
98
  'composer.multiquote.selectedToQuote',
82
99
  'composer.multiquote.added',
@@ -9,6 +9,7 @@ import { filterWords } from './word-filter'
9
9
  export const DISCOVERY_LABELS = {
10
10
  new: { labelKey: 'discovery.new.label', blurbKey: 'discovery.new.blurb' },
11
11
  today: { labelKey: 'discovery.today.label', blurbKey: 'discovery.today.blurb' },
12
+ unread: { labelKey: 'discovery.unread.label', blurbKey: 'discovery.unread.blurb' },
12
13
  mine: { labelKey: 'discovery.mine.label', blurbKey: 'discovery.mine.blurb' },
13
14
  participated: {
14
15
  labelKey: 'discovery.participated.label',
@@ -59,7 +60,7 @@ export function buildDiscoveryView(input: {
59
60
  rows: input.rows.map((row) => ({
60
61
  threadId: row.threadId,
61
62
  title: filterWords(row.title, input.wordFilter),
62
- href: `/thread/${row.threadId}-${row.slug}`,
63
+ href: `/thread/${row.threadId}-${row.slug}${input.view === 'unread' ? '?goto=unread' : ''}`,
63
64
  forum: { label: row.forumTitle, href: `/${row.forumId}-${row.forumSlug}` },
64
65
  authorUsername: row.authorUsername,
65
66
  replyCount: count(row.replyCount, input.t),
@@ -0,0 +1,45 @@
1
+ import type { DraftSummary } from '@meith/drafts'
2
+ import type { Translator } from '@meith/i18n'
3
+ import type { TimeModel } from '@meith/theme-kit'
4
+
5
+ import { copyFor } from './copy'
6
+ import { formatTime, untranslated } from './time'
7
+
8
+ export function draftsPageCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
9
+ return copyFor(['draftsPage.delete'], t)
10
+ }
11
+
12
+ export interface DraftRowView {
13
+ readonly key: string
14
+ readonly kind: 'thread' | 'reply'
15
+ readonly kindLabel: string
16
+ readonly targetName: string
17
+ readonly resumeHref: string
18
+ readonly forumId: number
19
+ readonly threadId: number | null
20
+ readonly updatedAt: TimeModel
21
+ }
22
+
23
+ export function buildDraftsView(input: {
24
+ readonly drafts: readonly DraftSummary[]
25
+ readonly now: Date
26
+ readonly t?: Translator
27
+ }): readonly DraftRowView[] {
28
+ const t = input.t ?? untranslated()
29
+
30
+ return input.drafts.map((draft) => {
31
+ const isReply = draft.threadId !== null
32
+ return {
33
+ key: isReply ? `thread:${draft.threadId}` : `forum:${draft.forumId}`,
34
+ kind: isReply ? 'reply' : 'thread',
35
+ kindLabel: t.t(isReply ? 'draftsPage.kind.reply' : 'draftsPage.kind.thread'),
36
+ targetName: isReply ? (draft.threadTitle ?? draft.forumTitle) : draft.forumTitle,
37
+ resumeHref: isReply
38
+ ? `/thread/${draft.threadId}-${draft.threadSlug}/reply`
39
+ : `/${draft.forumId}-${draft.forumSlug}/new`,
40
+ forumId: draft.forumId,
41
+ threadId: draft.threadId,
42
+ updatedAt: formatTime(draft.updatedAt, input.now, t),
43
+ }
44
+ })
45
+ }
@@ -32,6 +32,12 @@ const TOOLS: readonly ToolSpec[] = [
32
32
  placeholderKey: 'composer.placeholder.struck',
33
33
  },
34
34
  { tag: 'link', key: 'composer.tool.link', shortcut: 'k', placeholderKey: null },
35
+ {
36
+ tag: 'image',
37
+ key: 'composer.tool.image',
38
+ shortcut: null,
39
+ placeholderKey: 'composer.placeholder.image',
40
+ },
35
41
  { tag: 'quote', key: 'composer.tool.quote', shortcut: null, placeholderKey: null },
36
42
  { tag: 'code', key: 'composer.tool.code', shortcut: null, placeholderKey: null },
37
43
  {
@@ -42,13 +48,21 @@ const TOOLS: readonly ToolSpec[] = [
42
48
  },
43
49
  { tag: 'bulletedList', key: 'composer.tool.bulletedList', shortcut: null, placeholderKey: null },
44
50
  { tag: 'numberedList', key: 'composer.tool.numberedList', shortcut: null, placeholderKey: null },
51
+ { tag: 'taskList', key: 'composer.tool.taskList', shortcut: null, placeholderKey: null },
45
52
  { tag: 'heading', key: 'composer.tool.heading', shortcut: null, placeholderKey: null },
53
+ {
54
+ tag: 'table',
55
+ key: 'composer.tool.table',
56
+ shortcut: null,
57
+ placeholderKey: 'composer.placeholder.table',
58
+ },
46
59
  ]
47
60
 
48
61
  function button(spec: ToolSpec, t: Translator): EditorToolbarButtonModel {
49
62
  const label = t.t(spec.key)
50
63
  return {
51
64
  tag: spec.tag,
65
+ insertion: null,
52
66
  label,
53
67
  icon: null,
54
68
  title:
@@ -11,7 +11,7 @@ import type {
11
11
  } from '@meith/theme-kit'
12
12
  import type { ReadState, ThreadListingRow, ThreadPage } from '@meith/threads'
13
13
 
14
- import { forumHref } from './board-index'
14
+ import { forumHref, hydrateForumRow } from './board-index'
15
15
  import { count } from './count'
16
16
  import { type MemberIdentity, nameClassOf } from './member-identity'
17
17
  import { memberHref } from './member-profile'
@@ -53,9 +53,22 @@ function lastPost(
53
53
  function forum(
54
54
  row: ForumListingRow,
55
55
  ownThreadsOnlyForumIds: ReadonlySet<number> | undefined,
56
+ unreadForumIds: ReadonlySet<number> | undefined,
57
+ now: Date,
56
58
  t: Translator | undefined,
59
+ identities: ReadonlyMap<number, MemberIdentity> | undefined,
60
+ wordFilter: CompiledWordFilter | undefined,
57
61
  ): ForumRowModel {
58
62
  const ownThreadsOnly = ownThreadsOnlyForumIds?.has(row.id) ?? false
63
+ const hydration = hydrateForumRow(
64
+ row,
65
+ ownThreadsOnly,
66
+ unreadForumIds,
67
+ now,
68
+ t,
69
+ identities,
70
+ wordFilter,
71
+ )
59
72
  return {
60
73
  id: row.id,
61
74
  title: row.title,
@@ -64,8 +77,8 @@ function forum(
64
77
  type: row.type,
65
78
  threadCount: count(ownThreadsOnly ? 0 : row.threadCount, t),
66
79
  postCount: count(ownThreadsOnly ? 0 : row.postCount, t),
67
- lastPost: null,
68
- isUnread: false,
80
+ lastPost: hydration.lastPost,
81
+ isUnread: hydration.isUnread,
69
82
  subforums: [],
70
83
  }
71
84
  }
@@ -87,7 +100,7 @@ export function threadRowModel(
87
100
  return {
88
101
  id: row.id,
89
102
  title: filterWords(row.title, wordFilter),
90
- href: threadHref(row),
103
+ href: isUnread ? `${threadHref(row)}?goto=unread` : threadHref(row),
91
104
  prefix: row.prefix,
92
105
  author: {
93
106
  userId: row.authorUserId,
@@ -115,7 +128,7 @@ export interface ForumDisplayInput {
115
128
  readonly nextHref: string | null
116
129
  readonly pagination?: PaginationModel
117
130
  readonly newThreadHref?: string | null
118
- readonly readState?: Pick<ReadState, 'forumReadAt' | 'threadLastPostId'> | null
131
+ readonly readState?: Pick<ReadState, 'forumReadAt' | 'threadLastPostId' | 'unreadForumIds'> | null
119
132
  readonly markReadAction?: string | null
120
133
  readonly now: Date
121
134
  readonly t?: Translator
@@ -131,9 +144,18 @@ export interface ForumDisplayView {
131
144
  }
132
145
 
133
146
  export function buildForumDisplayView(input: ForumDisplayInput): ForumDisplayView {
147
+ const unreadForumIds = input.readState?.unreadForumIds
134
148
  return {
135
149
  display: {
136
- forum: forum(input.forum, input.ownThreadsOnlyForumIds, input.t),
150
+ forum: forum(
151
+ input.forum,
152
+ input.ownThreadsOnlyForumIds,
153
+ unreadForumIds,
154
+ input.now,
155
+ input.t,
156
+ input.identities,
157
+ input.wordFilter,
158
+ ),
137
159
  newThreadHref: input.newThreadHref ?? null,
138
160
  markReadAction: input.markReadAction ?? null,
139
161
  },
@@ -141,7 +163,17 @@ export function buildForumDisplayView(input: ForumDisplayInput): ForumDisplayVie
141
163
  input.subforums.length === 0
142
164
  ? null
143
165
  : {
144
- forums: input.subforums.map((row) => forum(row, input.ownThreadsOnlyForumIds, input.t)),
166
+ forums: input.subforums.map((row) =>
167
+ forum(
168
+ row,
169
+ input.ownThreadsOnlyForumIds,
170
+ unreadForumIds,
171
+ input.now,
172
+ input.t,
173
+ input.identities,
174
+ input.wordFilter,
175
+ ),
176
+ ),
145
177
  },
146
178
  threads: input.page.rows.map((row) =>
147
179
  threadRowModel(