@meith/web 0.31.0 → 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
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
4
  import { SubscriptionService } from '@meith/subscriptions'
5
+ import { TextLink } from '@meith/ui'
5
6
 
6
7
  import { SubscriptionRowForm } from '@/components/account/subscription-forms'
7
8
  import { BoardNotice } from '@/components/shell/board-notice'
@@ -52,12 +53,9 @@ export default async function SubscriptionsPage({
52
53
  lede={
53
54
  <>
54
55
  What you follow, and how often you hear about it.{' '}
55
- <a
56
- href="/notifications/preferences"
57
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
58
- >
56
+ <TextLink href="/notifications/preferences">
59
57
  {await tr('page.whether-any-it-also-arrives')}
60
- </a>{' '}
58
+ </TextLink>{' '}
61
59
  is a separate setting.
62
60
  </>
63
61
  }
@@ -120,12 +118,9 @@ function Section({
120
118
  {rows.map((row) => (
121
119
  <li key={row.key} className="rounded-lg border border-border bg-card p-4">
122
120
  <div className="flex flex-wrap items-baseline justify-between gap-2">
123
- <a
124
- href={row.href}
125
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
126
- >
121
+ <TextLink href={row.href} size="sm">
127
122
  {row.title}
128
- </a>
123
+ </TextLink>
129
124
  <span className="text-xs text-muted-foreground">
130
125
  {since[0]}
131
126
  <time dateTime={row.since.iso}>{row.since.label}</time>
@@ -4,11 +4,18 @@ import { notFound } from 'next/navigation'
4
4
  import { requireSlot, slotCopy } from '@meith/theme-kit'
5
5
 
6
6
  import { DeletePostForm, EditPostForm, RestorePostForm } from '@/components/content/edit-post-form'
7
+ import {
8
+ attachmentLimits as attachmentLimitsFor,
9
+ attachmentsForPosts,
10
+ canAttach,
11
+ resolveEditAttachmentScope,
12
+ } from '@/server/attachments'
7
13
  import { getActor } from '@/server/context'
8
14
  import { getTranslator, tr } from '@/server/i18n'
9
15
  import { filterView, viewerRef } from '@/server/plugin-view'
10
16
  import { resolvePostScope } from '@/server/post-scope'
11
17
  import { currentTheme } from '@/server/theme'
18
+ import { editableAttachment } from '@/view/attachments'
12
19
  import { editPostFormCopy } from '@/view/content-copy'
13
20
  import { buildEditorToolbarModel } from '@/view/editor-toolbar'
14
21
  import { buildEditView } from '@/view/post-form'
@@ -73,6 +80,18 @@ export default async function EditPostPage({
73
80
  <EditorToolbar {...toolbarModel} copy={slotCopy(theme, 'EditorToolbar', translator)} />
74
81
  )
75
82
 
83
+ const editingActor = await getActor()
84
+ const attachmentScope = editable
85
+ ? await resolveEditAttachmentScope(editingActor, scope.target.forum.id)
86
+ : null
87
+ const existingAttachments = editable
88
+ ? (await attachmentsForPosts([post])).map(editableAttachment)
89
+ : []
90
+ const editAttachmentLimits =
91
+ attachmentScope !== null && canAttach(editingActor, attachmentScope)
92
+ ? attachmentLimitsFor(attachmentScope)
93
+ : null
94
+
76
95
  const formModel = await filterView(
77
96
  'view.post-form',
78
97
  {
@@ -89,6 +108,8 @@ export default async function EditPostPage({
89
108
  postId={post}
90
109
  message={scope.target.post.message}
91
110
  reason={null}
111
+ attachments={existingAttachments}
112
+ attachmentLimits={editAttachmentLimits}
92
113
  toolbar={editorToolbar}
93
114
  />
94
115
  )}
@@ -1,13 +1,14 @@
1
1
  import { activeWordFilter } from '@/server/content-admin'
2
2
  import { feedFor } from '@/server/feed-builder'
3
3
  import { feedResponse, noFeed, offlineFeed } from '@/server/feed-routes'
4
- import { FEED_LIMIT, feedRepository, origin, publicScope } from '@/server/syndication'
4
+ import { feedScopeForRequest } from '@/server/feed-token'
5
+ import { FEED_LIMIT, feedRepository, origin } from '@/server/syndication'
5
6
  import { leadingId } from '@/view/slug-id'
6
7
 
7
8
  export const dynamic = 'force-dynamic'
8
9
 
9
10
  export async function GET(
10
- _request: Request,
11
+ request: Request,
11
12
  { params }: { params: Promise<{ slug: string }> },
12
13
  ): Promise<Response> {
13
14
  const offline = await offlineFeed()
@@ -20,7 +21,8 @@ export async function GET(
20
21
  const id = leadingId(slug)
21
22
  if (id === null) return noFeed()
22
23
 
23
- const posts = await repo.recentPosts(id, FEED_LIMIT, await publicScope())
24
+ const { scope, tokened } = await feedScopeForRequest(request)
25
+ const posts = await repo.recentPosts(id, FEED_LIMIT, scope)
24
26
  if (posts.length === 0) return noFeed()
25
27
 
26
28
  const first = posts[0]!
@@ -38,5 +40,6 @@ export async function GET(
38
40
  }),
39
41
  'rss',
40
42
  'thread',
43
+ { private: tokened },
41
44
  )
42
45
  }
@@ -1,5 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound, redirect } from 'next/navigation'
3
+ import { after as scheduleAfterResponse } from 'next/server'
3
4
 
4
5
  import { currentRequestId } from '@meith/core/logger'
5
6
  import { acceptsThreads, canHoldThreads } from '@meith/forums'
@@ -19,6 +20,7 @@ import { ThreadToolsForm } from '@/components/moderation/thread-tools-form'
19
20
  import { BoardNotice } from '@/components/shell/board-notice'
20
21
  import { BOARD_MEASURE } from '@/components/shell/measure'
21
22
  import { attachmentLimits, attachmentsForPosts, canAttach } from '@/server/attachments'
23
+ import { autoWatchChecksByDefault } from '@/server/auto-watch'
22
24
  import { avatarsFor } from '@/server/avatars'
23
25
  import { getContainer } from '@/server/container'
24
26
  import { activeVocabulary, activeWordFilter } from '@/server/content-admin'
@@ -30,12 +32,14 @@ import { cspNonce } from '@/server/nonce'
30
32
  import { pageMetadata } from '@/server/page-metadata'
31
33
  import { filterView, pluginRegion, viewerRef } from '@/server/plugin-view'
32
34
  import { resolvePollScope } from '@/server/poll-scope'
35
+ import { presenceFor } from '@/server/presence'
33
36
  import { postbitProfileFields } from '@/server/profile-fields'
34
37
  import { viewerIgnoredIds } from '@/server/relations'
35
38
  import { reputationSettings, thanksForPosts } from '@/server/reputation'
36
39
  import { getSettings } from '@/server/settings'
37
40
  import { signaturesFor } from '@/server/signatures'
38
41
  import { currentTheme } from '@/server/theme'
42
+ import { resolveUnreadGoto } from '@/server/unread-goto'
39
43
  import { getViewerPreferences } from '@/server/viewer-preferences'
40
44
  import { followFormCopy } from '@/view/account-copy'
41
45
  import { attachmentsByPost } from '@/view/attachments'
@@ -171,6 +175,7 @@ export default async function ThreadPage({
171
175
  replied?: string
172
176
  posted?: string
173
177
  post?: string
178
+ goto?: string
174
179
  removed?: string
175
180
  unchanged?: string
176
181
  tool?: string
@@ -194,6 +199,7 @@ export default async function ThreadPage({
194
199
  posts,
195
200
  threads,
196
201
  authorizer,
202
+ readState,
197
203
  threadViews,
198
204
  threadWrites,
199
205
  postWrites,
@@ -236,6 +242,19 @@ export default async function ThreadPage({
236
242
  }
237
243
  }
238
244
 
245
+ if (query.goto === 'unread' && actor.userId !== null && readState !== null) {
246
+ const location = await resolveUnreadGoto(
247
+ posts,
248
+ readState,
249
+ actor.userId,
250
+ { id: thread.id, forumId: forum.id, lastPostId: thread.lastPost?.postId ?? null },
251
+ { scope, pageSize: preferences.postsPerPage },
252
+ )
253
+ if (location !== null) {
254
+ redirect(locatedHref(`/thread/${thread.id}-${thread.slug}`, query, location))
255
+ }
256
+ }
257
+
239
258
  if (threadViews && after === undefined) {
240
259
  await threadViews.record(thread.id).catch(() => undefined)
241
260
  }
@@ -248,6 +267,18 @@ export default async function ThreadPage({
248
267
  scope,
249
268
  })
250
269
 
270
+ if (readState !== null && actor.userId !== null) {
271
+ const readerId = actor.userId
272
+ const lastRenderedPostId = postPage.rows.at(-1)?.id
273
+ if (lastRenderedPostId !== undefined) {
274
+ scheduleAfterResponse(() =>
275
+ readState
276
+ .markThreadRead(readerId, thread.id, lastRenderedPostId, new Date())
277
+ .catch(() => undefined),
278
+ )
279
+ }
280
+ }
281
+
251
282
  const pager = buildOffsetPager({
252
283
  path: `/thread/${thread.id}-${thread.slug}`,
253
284
  params: query,
@@ -350,12 +381,25 @@ export default async function ThreadPage({
350
381
 
351
382
  const identities = await identitiesFor(authorIds)
352
383
 
384
+ const presence = await presenceFor(authorIds)
385
+
353
386
  const attachments = attachmentsByPost(
354
387
  await attachmentsForPosts(postPage.rows.map((row) => row.id)),
355
388
  )
356
389
 
357
390
  const wordFilter = await activeWordFilter()
358
391
 
392
+ const { subscriptions } = getContainer()
393
+ const followMode =
394
+ subscriptions === null || actor.userId === null
395
+ ? null
396
+ : await subscriptions.modeFor(actor.userId, 'thread', thread.id)
397
+ const followOffered = subscriptions !== null && actor.userId !== null
398
+ const followModes = buildSubscriptionsView({
399
+ rows: [],
400
+ now: new Date(),
401
+ }).modes
402
+
359
403
  const view = buildThreadView({
360
404
  thread,
361
405
  wordFilter,
@@ -371,6 +415,8 @@ export default async function ThreadPage({
371
415
  actor.userId === null || postPage.rows.at(-1) === undefined
372
416
  ? null
373
417
  : `/api/read/thread/${thread.id}?post=${postPage.rows.at(-1)!.id}`,
418
+ watchOffered: followOffered,
419
+ watchMode: followMode,
374
420
  now: new Date(),
375
421
  t: await getTranslator(),
376
422
  authorFields,
@@ -378,6 +424,8 @@ export default async function ThreadPage({
378
424
  attachments,
379
425
  avatars,
380
426
  identities,
427
+ presence,
428
+ viewerSeesInvisible: authorizer.can(actor, 'modcp.access'),
381
429
  ignoredIds,
382
430
  revealedPostIds: revealedFrom(query.reveal),
383
431
  currentHref:
@@ -385,17 +433,6 @@ export default async function ThreadPage({
385
433
  (after === undefined ? `?page=${page}` : `?after=${after}&page=${page}`),
386
434
  })
387
435
 
388
- const { subscriptions } = getContainer()
389
- const followMode =
390
- subscriptions === null || actor.userId === null
391
- ? null
392
- : await subscriptions.modeFor(actor.userId, 'thread', thread.id)
393
- const followOffered = subscriptions !== null && actor.userId !== null
394
- const followModes = buildSubscriptionsView({
395
- rows: [],
396
- now: new Date(),
397
- }).modes
398
-
399
436
  const theme = await currentTheme()
400
437
  const ThreadView = requireSlot(theme, 'ThreadView')
401
438
  const Navigation = requireSlot(theme, 'Navigation')
@@ -548,6 +585,7 @@ export default async function ThreadPage({
548
585
  seenLastPostId={thread.lastPost?.postId ?? null}
549
586
  prefill=""
550
587
  canSubscribe={authorizer.can(actor, 'forum.subscribe', replyTarget)}
588
+ subscribeDefault={autoWatchChecksByDefault(preferences.autoWatchRepliedThreads)}
551
589
  attachmentLimits={quickReplyAttachable ? attachmentLimits(replyTarget) : null}
552
590
  draft={
553
591
  actor.userId === null || drafts === null
@@ -8,6 +8,7 @@ import { MultiQuoteSelection } from '@/components/content/multiquote-selection'
8
8
  import { ReplyForm } from '@/components/content/reply-form'
9
9
  import { OnboardingBanner } from '@/components/shell/onboarding-banner'
10
10
  import { attachmentLimits, canAttach } from '@/server/attachments'
11
+ import { autoWatchChecksByDefault, autoWatchPreference } from '@/server/auto-watch'
11
12
  import { getContainer } from '@/server/container'
12
13
  import { activeWordFilter } from '@/server/content-admin'
13
14
  import { getActor } from '@/server/context'
@@ -86,6 +87,9 @@ export default async function ReplyPage({
86
87
  const EditorToolbar = requireSlot(theme, 'EditorToolbar')
87
88
  const translator = await getTranslator()
88
89
  const attachable = canAttach(actor, scope)
90
+ const subscribeDefault = autoWatchChecksByDefault(
91
+ await autoWatchPreference(actor.userId, 'reply'),
92
+ )
89
93
  const toolbarModel = locked
90
94
  ? null
91
95
  : await filterView(
@@ -113,6 +117,7 @@ export default async function ReplyPage({
113
117
  seenLastPostId={target.lastPostId}
114
118
  prefill={prefill}
115
119
  canSubscribe={authorizer.can(actor, 'forum.subscribe', scope)}
120
+ subscribeDefault={subscribeDefault}
116
121
  attachmentLimits={attachable ? attachmentLimits(scope) : null}
117
122
  draft={
118
123
  actor.userId === null || drafts === null
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
2
2
 
3
3
  import { env } from '@meith/core'
4
4
  import { readUnsubscribeToken } from '@meith/subscriptions'
5
+ import { TextLink } from '@meith/ui'
5
6
 
6
7
  import { UnsubscribeConfirmForm } from '@/components/account/subscription-forms'
7
8
  import { BoardNotice } from '@/components/shell/board-notice'
@@ -36,24 +37,18 @@ export default async function UnsubscribePage({
36
37
  <BoardNotice kind="info" message={done} dismissHref="/" />
37
38
  <p className="text-sm text-muted-foreground">
38
39
  {translator.t('unsubscribePage.doneBefore')}{' '}
39
- <a
40
- href="/subscriptions"
41
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
42
- >
40
+ <TextLink href="/subscriptions">
43
41
  {translator.t('unsubscribePage.subscriptions')}
44
- </a>
42
+ </TextLink>
45
43
  {translator.t('unsubscribePage.doneAfter')}
46
44
  </p>
47
45
  </>
48
46
  ) : claim === null ? (
49
47
  <p className="text-sm text-muted-foreground">
50
48
  {translator.t('unsubscribePage.invalidBefore')}{' '}
51
- <a
52
- href="/subscriptions"
53
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
54
- >
49
+ <TextLink href="/subscriptions">
55
50
  {translator.t('unsubscribePage.subscriptions')}
56
- </a>{' '}
51
+ </TextLink>{' '}
57
52
  {translator.t('unsubscribePage.invalidAfter')}
58
53
  </p>
59
54
  ) : (
@@ -64,7 +59,9 @@ export default async function UnsubscribePage({
64
59
  ? await tr('page.unsubscribe.massMailScope')
65
60
  : claim.scope === 'email'
66
61
  ? await tr('page.unsubscribe.emailScope')
67
- : await tr('page.unsubscribe.targetScope')
62
+ : claim.scope === 'board-digest'
63
+ ? await tr('page.unsubscribe.boardDigestScope')
64
+ : await tr('page.unsubscribe.targetScope')
68
65
  }
69
66
  copy={followFormCopy(translator)}
70
67
  />
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
4
  import { MAX_RELATIONS } from '@meith/relations'
5
+ import { TextLink } from '@meith/ui'
5
6
 
6
7
  import { RemoveRelationForm } from '@/components/account/relation-forms'
7
8
  import { BoardNotice } from '@/components/shell/board-notice'
@@ -123,12 +124,9 @@ async function ContactLine({ row }: { row: ContactRowView }) {
123
124
 
124
125
  <span className="ml-auto flex items-center gap-4">
125
126
  {row.messageHref !== null && (
126
- <a
127
- href={row.messageHref}
128
- className="text-sm font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
129
- >
127
+ <TextLink href={row.messageHref} size="sm">
130
128
  {translator.t('board.contacts.message')}
131
- </a>
129
+ </TextLink>
132
130
  )}
133
131
  <RemoveRelationForm userId={row.userId} username={row.username} returnTo={RETURN_TO} />
134
132
  </span>
@@ -0,0 +1,70 @@
1
+ import type { Metadata } from 'next'
2
+ import { notFound } from 'next/navigation'
3
+
4
+ import { buttonVariants, TextLink } from '@meith/ui'
5
+
6
+ import { DeleteDraftForm } from '@/components/account/draft-forms'
7
+ import { PanelPage } from '@/components/shell/panel-page'
8
+ import { getContainer } from '@/server/container'
9
+ import { getActor } from '@/server/context'
10
+ import { getTranslator, tr } from '@/server/i18n'
11
+ import { buildDraftsView, draftsPageCopy } from '@/view/drafts'
12
+
13
+ export async function generateMetadata(): Promise<Metadata> {
14
+ return { title: await tr('page.drafts') }
15
+ }
16
+
17
+ export default async function DraftsPage() {
18
+ const actor = await getActor()
19
+ const { drafts } = getContainer()
20
+ if (actor.userId === null || drafts === null) notFound()
21
+
22
+ const translator = await getTranslator()
23
+ const rows = buildDraftsView({
24
+ drafts: await drafts.listByUser(actor.userId),
25
+ now: new Date(),
26
+ t: translator,
27
+ })
28
+ const copy = draftsPageCopy(translator)
29
+ const resumeLabel = await tr('draftsPage.resume')
30
+
31
+ return (
32
+ <PanelPage title={await tr('page.drafts')} lede={await tr('draftsPage.lede')}>
33
+ {rows.length === 0 ? (
34
+ <p className="rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground">
35
+ {await tr('draftsPage.none')}
36
+ </p>
37
+ ) : (
38
+ <ul className="flex flex-col gap-3">
39
+ {rows.map((row) => (
40
+ <li key={row.key} className="rounded-lg border border-border bg-card p-4">
41
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
42
+ <span className="flex flex-col gap-0.5">
43
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
44
+ {row.kindLabel}
45
+ </span>
46
+ <TextLink href={row.resumeHref} size="sm">
47
+ {row.targetName}
48
+ </TextLink>
49
+ </span>
50
+ <time dateTime={row.updatedAt.iso} className="text-xs text-muted-foreground">
51
+ {row.updatedAt.label}
52
+ </time>
53
+ </div>
54
+
55
+ <div className="mt-3 flex flex-wrap items-center gap-2">
56
+ <a
57
+ href={row.resumeHref}
58
+ className={buttonVariants({ variant: 'primary', size: 'sm' })}
59
+ >
60
+ {resumeLabel}
61
+ </a>
62
+ <DeleteDraftForm forumId={row.forumId} threadId={row.threadId} copy={copy} />
63
+ </div>
64
+ </li>
65
+ ))}
66
+ </ul>
67
+ )}
68
+ </PanelPage>
69
+ )
70
+ }
@@ -9,7 +9,13 @@ import { getActor } from '@/server/context'
9
9
  import { catalogs, getTranslator, tr } from '@/server/i18n'
10
10
  import { getSettings } from '@/server/settings'
11
11
  import { optionsFormCopy } from '@/view/account-copy'
12
- import { localeChoices, optionsFormValues, timezoneChoices, userCpNotice } from '@/view/usercp'
12
+ import {
13
+ autoWatchChoices,
14
+ localeChoices,
15
+ optionsFormValues,
16
+ timezoneChoices,
17
+ userCpNotice,
18
+ } from '@/view/usercp'
13
19
 
14
20
  export async function generateMetadata(): Promise<Metadata> {
15
21
  return { title: await tr('page.options') }
@@ -45,6 +51,7 @@ export default async function OptionsPage({
45
51
  {...values}
46
52
  timezones={timezoneChoices(await getTranslator())}
47
53
  locales={localeChoices(catalogs.locales, await getTranslator())}
54
+ autoWatchChoices={autoWatchChoices()}
48
55
  copy={optionsFormCopy(
49
56
  board.get('display.posts_per_page'),
50
57
  board.get('display.threads_per_page'),
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation'
4
4
  import { PASSKEY_LIMIT, providerLabel } from '@meith/accounts'
5
5
 
6
6
  import { ActiveSessions, type SessionView } from '@/components/account/active-sessions'
7
+ import { FeedTokenPanel } from '@/components/account/feed-token'
7
8
  import { PasskeyEnrol } from '@/components/account/passkey-enrol'
8
9
  import { type ActivityView, SecurityActivity } from '@/components/account/security-activity'
9
10
  import {
@@ -22,6 +23,7 @@ import { memberSecurityActivity } from '@/server/auth-events'
22
23
  import { getContainer } from '@/server/container'
23
24
  import { getActor } from '@/server/context'
24
25
  import { memberManagedSignIns, passkeysEnabled, signInProviders } from '@/server/federation'
26
+ import { feedTokenSummary } from '@/server/feed-token'
25
27
  import { getTranslator, tr } from '@/server/i18n'
26
28
  import { currentSessionId } from '@/server/session-actions'
27
29
  import { pendingEnrolment, secondFactorPosture, twoFactorState } from '@/server/two-factor'
@@ -29,6 +31,8 @@ import {
29
31
  activeSessionsCopy,
30
32
  activityDetailLabel,
31
33
  emailFormCopy,
34
+ feedTokenCopy,
35
+ feedTokenDetailLabel,
32
36
  linkedUsageLabel,
33
37
  passkeyUsageLabel,
34
38
  passwordFormCopy,
@@ -159,6 +163,12 @@ export default async function SecurityPage({
159
163
 
160
164
  const anywhereToSignIn = providers.length > 0 || linked.length > 0
161
165
 
166
+ const feedToken = await feedTokenSummary(actor.userId)
167
+ const feedTokenDetail =
168
+ feedToken === null
169
+ ? null
170
+ : feedTokenDetailLabel(on(feedToken.createdAt) ?? '', on(feedToken.lastUsedAt), translator)
171
+
162
172
  return (
163
173
  <PanelPage
164
174
  title={await tr('page.account-security')}
@@ -211,6 +221,12 @@ export default async function SecurityPage({
211
221
 
212
222
  <ActiveSessions sessions={sessionView} copy={activeSessionsCopy(translator)} />
213
223
 
224
+ <FeedTokenPanel
225
+ active={feedToken !== null}
226
+ detail={feedTokenDetail}
227
+ copy={feedTokenCopy(translator)}
228
+ />
229
+
214
230
  <SecurityActivity events={activity} copy={securityActivityCopy(translator)} />
215
231
  </PanelPage>
216
232
  )
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
- import { cn } from '@meith/ui'
3
+ import { cn, TextLink } from '@meith/ui'
4
4
 
5
5
  import { CaptchaQuestionRowForm, NewCaptchaQuestionForm } from '@/components/admin/content-forms'
6
6
  import { PANEL_CARD } from '@/components/shell/panel-list'
@@ -34,12 +34,9 @@ export default async function AdminAntispamPage() {
34
34
  lede={
35
35
  <>
36
36
  The thresholds are in{' '}
37
- <a
38
- href="/admin/settings?group=antispam"
39
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
40
- >
37
+ <TextLink href="/admin/settings?group=antispam">
41
38
  {await tr('page.settings-anti-spam')}
42
- </a>
39
+ </TextLink>
43
40
  {translator.t('adminAntispam.ledeEnd')}
44
41
  </>
45
42
  }
@@ -115,12 +112,7 @@ export default async function AdminAntispamPage() {
115
112
  </dt>
116
113
  <dd>
117
114
  {translator.t('adminAntispam.firstPostsBefore')}{' '}
118
- <a
119
- href="/modcp"
120
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
121
- >
122
- {translator.t('adminAntispam.moderationQueue')}
123
- </a>
115
+ <TextLink href="/modcp">{translator.t('adminAntispam.moderationQueue')}</TextLink>
124
116
  {translator.t('adminAntispam.firstPostsAfter')}
125
117
  </dd>
126
118
  </div>
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
- import { cn } from '@meith/ui'
3
+ import { cn, TextLink } from '@meith/ui'
4
4
 
5
5
  import { DeleteAttachmentForm } from '@/components/admin/content-forms'
6
6
  import { PANEL_CARD, PANEL_LIST, PANEL_NOTE, PANEL_ROW } from '@/components/shell/panel-list'
@@ -160,12 +160,9 @@ export default async function AdminAttachmentsPage({
160
160
  {row.threadSlug !== null && (
161
161
  <>
162
162
  {' · '}
163
- <a
164
- href={postLink(`/thread/${row.threadSlug}`, row.postId)}
165
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
166
- >
163
+ <TextLink href={postLink(`/thread/${row.threadSlug}`, row.postId)}>
167
164
  {translator.t('adminAttachments.post')}
168
- </a>
165
+ </TextLink>
169
166
  </>
170
167
  )}
171
168
  </span>
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
2
 
3
- import { cn } from '@meith/ui'
3
+ import { cn, TextLink } from '@meith/ui'
4
4
 
5
5
  import {
6
6
  DeletePrefixForm,
@@ -107,23 +107,13 @@ export default async function AdminContentPage() {
107
107
  <section className={cn(PANEL_CARD, 'gap-2 text-sm')}>
108
108
  <h2 className="font-heading text-lg font-semibold">{t.t('contentPage.announcements')}</h2>
109
109
  <p className="text-muted-foreground">{t.t('contentPage.announcementsHelp')}</p>
110
- <a
111
- href="/admin/content/announcements"
112
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
113
- >
114
- {await tr('page.announcements-b')}
115
- </a>
110
+ <TextLink href="/admin/content/announcements">{await tr('page.announcements-b')}</TextLink>
116
111
  </section>
117
112
 
118
113
  <section className={cn(PANEL_CARD, 'gap-2 text-sm')}>
119
114
  <h2 className="font-heading text-lg font-semibold">{t.t('contentPage.attachments')}</h2>
120
115
  <p className="text-muted-foreground">{t.t('contentPage.attachmentsHelp')}</p>
121
- <a
122
- href="/admin/content/attachments"
123
- className="font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground"
124
- >
125
- {await tr('page.attachments-b')}
126
- </a>
116
+ <TextLink href="/admin/content/attachments">{await tr('page.attachments-b')}</TextLink>
127
117
  </section>
128
118
 
129
119
  <section className={PANEL_CARD}>
@@ -1,7 +1,7 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
 
4
- import { CopyPermissionsForm, ForumPermissionRowForm } from '@/components/admin/forum-forms'
4
+ import { CopyPermissionsForm, ForumPermissionMatrixForm } from '@/components/admin/forum-forms'
5
5
  import { PANEL_CARD } from '@/components/shell/panel-list'
6
6
  import { PanelPage } from '@/components/shell/panel-page'
7
7
  import { adminPageContext } from '@/server/admin'
@@ -42,18 +42,15 @@ export default async function ForumPermissionsPage({
42
42
  </>
43
43
  }
44
44
  gap="loose"
45
+ width="wide"
45
46
  >
46
- <div className="flex flex-col gap-6">
47
- {view.rows.map((row) => (
48
- <ForumPermissionRowForm
49
- key={row.groupId}
50
- forumId={view.forum.id}
51
- row={row}
52
- forumTitles={forumTitles}
53
- copy={forumAdminCopy(t)}
54
- />
55
- ))}
56
- </div>
47
+ <ForumPermissionMatrixForm
48
+ forumId={view.forum.id}
49
+ columns={view.columns}
50
+ fields={view.fields}
51
+ forumTitles={forumTitles}
52
+ copy={forumAdminCopy(t)}
53
+ />
57
54
 
58
55
  <section className={PANEL_CARD}>
59
56
  <h2 className="font-heading text-lg font-semibold">{await tr('page.copy-subforums')}</h2>