@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
@@ -47,6 +47,29 @@ export function apiTokenFormsCopy(
47
47
  )
48
48
  }
49
49
 
50
+ export function webhookFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
51
+ return copyFor(
52
+ [
53
+ 'adminPanel.webhook.copyNow',
54
+ 'adminPanel.webhook.url',
55
+ 'adminPanel.webhook.urlPlaceholder',
56
+ 'adminPanel.webhook.urlHint',
57
+ 'adminPanel.webhook.topics',
58
+ 'adminPanel.webhook.topicsHint',
59
+ 'adminPanel.webhook.format',
60
+ 'adminPanel.webhook.formatHint',
61
+ 'adminPanel.webhook.format.json',
62
+ 'adminPanel.webhook.format.discord',
63
+ 'adminPanel.webhook.active',
64
+ 'adminPanel.webhook.create',
65
+ 'adminPanel.webhook.enable',
66
+ 'adminPanel.webhook.disable',
67
+ 'adminPanel.webhook.delete',
68
+ ],
69
+ t,
70
+ )
71
+ }
72
+
50
73
  export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
51
74
  return {
52
75
  ...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(
@@ -1,3 +1,6 @@
1
+ import type { Translator } from '@meith/i18n'
2
+
3
+ import type { PluginNavSection } from './admin-nav'
1
4
  import {
2
5
  currentProps,
3
6
  deepestHrefIn,
@@ -6,6 +9,7 @@ import {
6
9
  type PanelSection,
7
10
  sectionHrefIn,
8
11
  } from './panel-nav'
12
+ import { untranslated } from './time'
9
13
 
10
14
  export { currentProps, isUnder }
11
15
 
@@ -67,6 +71,34 @@ export function modCpNav(access: ModCpNavAccess): PanelNav {
67
71
  return [MODCP_OVERVIEW, ...modCpSections(access)]
68
72
  }
69
73
 
74
+ export const MODCP_PLUGINS_HREF = '/modcp/plugins'
75
+
76
+ const MODCP_PLUGIN_KEY_PATTERN = /^\/modcp\/plugins\/([a-z][a-z0-9-]*)(?:\/|$|\?)/
77
+
78
+ export function pluginKeyAtModCp(pathname: string): string | null {
79
+ return MODCP_PLUGIN_KEY_PATTERN.exec(pathname)?.[1] ?? null
80
+ }
81
+
82
+ export function modCpNavWithPlugin(
83
+ access: ModCpNavAccess,
84
+ plugin: PluginNavSection | null,
85
+ t: Translator = untranslated(),
86
+ ): PanelNav {
87
+ const base = modCpNav(access)
88
+ if (plugin === null || plugin.pages.length === 0) return base
89
+
90
+ const href = `${MODCP_PLUGINS_HREF}/${plugin.key}`
91
+ const section: PanelSection = {
92
+ href,
93
+ titleText: plugin.name,
94
+ icon: 'plugins',
95
+ blurbText: t.t('modCpNav.plugin.blurb', { name: plugin.name }),
96
+ children: plugin.pages.filter((page) => page.href !== href),
97
+ }
98
+
99
+ return [...base, section]
100
+ }
101
+
70
102
  export function activeSectionHref(access: ModCpNavAccess, pathname: string): string | null {
71
103
  return sectionHrefIn(modCpNav(access), pathname)
72
104
  }
@@ -1,8 +1,23 @@
1
1
  import type { Translator } from '@meith/i18n'
2
+ import type { ReportCategory } from '@meith/moderation'
2
3
 
3
4
  import { copyFor, patternCopy, splitAround } from './copy'
4
5
  import { untranslated } from './time'
5
6
 
7
+ export const REPORT_CATEGORY_ORDER = [
8
+ 'spam',
9
+ 'off_topic',
10
+ 'abuse',
11
+ 'other',
12
+ ] as const satisfies readonly ReportCategory[]
13
+
14
+ export const REPORT_CATEGORY_LABEL_KEYS: Readonly<Record<ReportCategory, string>> = {
15
+ spam: 'moderationForm.report.category.spam',
16
+ off_topic: 'moderationForm.report.category.off_topic',
17
+ abuse: 'moderationForm.report.category.abuse',
18
+ other: 'moderationForm.report.category.other',
19
+ }
20
+
6
21
  export function moderationFormsCopy(
7
22
  t: Translator = untranslated(),
8
23
  ): Readonly<Record<string, string>> {
@@ -15,6 +30,11 @@ export function moderationFormsCopy(
15
30
  'moderationForm.report.label',
16
31
  'moderationForm.report.hint',
17
32
  'moderationForm.report.submit',
33
+ 'moderationForm.report.categoryLegend',
34
+ 'moderationForm.report.category.spam',
35
+ 'moderationForm.report.category.off_topic',
36
+ 'moderationForm.report.category.abuse',
37
+ 'moderationForm.report.category.other',
18
38
  'moderationForm.report.putBack',
19
39
  'moderationForm.report.take',
20
40
  'moderationForm.report.note',
@@ -78,6 +78,7 @@ export function notificationNotice(
78
78
  t: Translator = untranslated(),
79
79
  ): string | null {
80
80
  if (query.saved === 'announcements') return t.t('notice.announcementsSaved')
81
+ if (query.saved === 'digest-cadence') return t.t('notice.boardDigestCadenceSaved')
81
82
  if (query.saved !== undefined) return t.t('notice.preferencesSaved')
82
83
  if (query.read === 'all') return t.t('notice.allRead')
83
84
  if (query.read === 'one') return t.t('notice.markedRead')
@@ -37,6 +37,19 @@ export function pluginPanelTabs(input: {
37
37
  ]
38
38
  }
39
39
 
40
+ export function pluginStaffPanelTabs(input: {
41
+ readonly pages: readonly PluginPage[]
42
+ readonly current: string | null
43
+ }): readonly PluginPanelTab[] {
44
+ if (input.pages.length < 2) return []
45
+
46
+ return input.pages.map((page) => ({
47
+ href: page.href,
48
+ label: page.title,
49
+ isCurrent: page.path === input.current,
50
+ }))
51
+ }
52
+
40
53
  export function pluginNavChildren(pages: readonly PluginPage[]): readonly PanelSubsection[] {
41
54
  return pages.map((page) => ({ href: page.href, titleText: page.title }))
42
55
  }
@@ -0,0 +1,46 @@
1
+ import { type Poll, pollOptionShares } from '@meith/polls'
2
+
3
+ export interface PollOptionVoteView {
4
+ readonly id: number
5
+ readonly label: string
6
+ readonly votes: number
7
+ readonly share: number
8
+ readonly checked: boolean
9
+ readonly voterNames: readonly string[]
10
+ readonly moreVoters: number
11
+ }
12
+
13
+ export interface PollVoteView {
14
+ readonly options: readonly PollOptionVoteView[]
15
+ readonly total: number
16
+ readonly multiple: boolean
17
+ readonly closed: boolean
18
+ readonly hasVoted: boolean
19
+ readonly mayCast: boolean
20
+ readonly publicVotes: boolean
21
+ }
22
+
23
+ export function pollVoteView(poll: Poll, mayVote: boolean, now: Date): PollVoteView {
24
+ const total = poll.options.reduce((sum, option) => sum + option.votes, 0)
25
+ const shares = pollOptionShares(poll.options.map((option) => option.votes))
26
+ const closed = poll.closesAt !== null && poll.closesAt <= now
27
+ const hasVoted = poll.votedOptionIds.length > 0
28
+
29
+ return {
30
+ total,
31
+ multiple: poll.maxOptions !== 1,
32
+ closed,
33
+ hasVoted,
34
+ mayCast: mayVote && !closed && (!hasVoted || poll.allowRevote),
35
+ publicVotes: poll.publicVotes,
36
+ options: poll.options.map((option, index) => ({
37
+ id: option.id,
38
+ label: option.label,
39
+ votes: option.votes,
40
+ share: shares[index] ?? 0,
41
+ checked: poll.votedOptionIds.includes(option.id),
42
+ voterNames: poll.publicVotes ? option.voters.map((voter) => voter.username) : [],
43
+ moreVoters: poll.publicVotes ? Math.max(0, option.votes - option.voters.length) : 0,
44
+ })),
45
+ }
46
+ }
@@ -16,7 +16,14 @@ export function locatedHref(
16
16
  ): string {
17
17
  const params = new URLSearchParams()
18
18
  for (const [key, value] of Object.entries(query)) {
19
- if (value === undefined || key === 'post' || key === 'after' || key === 'page') continue
19
+ if (
20
+ value === undefined ||
21
+ key === 'post' ||
22
+ key === 'after' ||
23
+ key === 'page' ||
24
+ key === 'goto'
25
+ )
26
+ continue
20
27
  for (const one of typeof value === 'string' ? [value] : value) params.append(key, one)
21
28
  }
22
29
  if (location.afterId !== null) {
@@ -0,0 +1,5 @@
1
+ export const PROGRESSIVE_FIELD = 'clientEnhanced'
2
+
3
+ export function isEnhancedSubmit(form: FormData): boolean {
4
+ return form.get(PROGRESSIVE_FIELD) === '1'
5
+ }
package/src/view/shell.ts CHANGED
@@ -9,12 +9,13 @@ import type {
9
9
  ViewerModel,
10
10
  } from '@meith/theme-kit'
11
11
 
12
+ import { BOARD_TITLE } from './board-title'
12
13
  import { count } from './count'
13
14
  import { memberHref } from './member-profile'
14
15
  import { buildNavigation, defaultNavigationItems } from './navigation'
15
16
  import { timezoneLabel, untranslated } from './time'
16
17
 
17
- export const BOARD_TITLE = 'Meith'
18
+ export { BOARD_TITLE }
18
19
 
19
20
  export const TIMEZONE_LABEL = 'UTC'
20
21
 
@@ -80,6 +80,7 @@ export function unsubscribeNotice(
80
80
  ): string | null {
81
81
  if (done === 'announcements') return t.t('subscription.announcementsOff')
82
82
  if (done === 'email') return t.t('subscription.emailsOff')
83
+ if (done === 'boardDigest') return t.t('subscription.boardDigestOff')
83
84
  if (done === 'one') return t.t('subscription.doneOne')
84
85
  return null
85
86
  }