@meith/web 0.33.3 → 0.33.4

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.
@@ -644,6 +644,7 @@ export default async function ThreadPage({
644
644
  rights={toolRights}
645
645
  moveTargets={moveTargets}
646
646
  heading={threadToolsHeading(appointment.isForumModerator, await getTranslator())}
647
+ open={toolNotice !== undefined}
647
648
  >
648
649
  <ThreadSurgeryForm
649
650
  copy={moderationFormsCopy(await getTranslator())}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.33.3",
3
+ "version": "0.33.4",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,54 +43,54 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.33.3",
47
- "@meith/admin": "0.33.3",
48
- "@meith/antispam": "0.33.3",
49
- "@meith/api": "0.33.3",
50
- "@meith/attachments": "0.33.3",
51
- "@meith/authorization": "0.33.3",
52
- "@meith/avatars": "0.33.3",
53
- "@meith/board-digest": "0.33.3",
54
- "@meith/core": "0.33.3",
55
- "@meith/db": "0.33.3",
56
- "@meith/demo": "0.33.3",
57
- "@meith/drafts": "0.33.3",
58
- "@meith/drivers": "0.33.3",
59
- "@meith/events": "0.33.3",
60
- "@meith/forums": "0.33.3",
61
- "@meith/groups": "0.33.3",
62
- "@meith/i18n": "0.33.3",
63
- "@meith/import": "0.33.3",
64
- "@meith/install": "0.33.3",
65
- "@meith/mail": "0.33.3",
66
- "@meith/markdown": "0.33.3",
67
- "@meith/marketplace": "0.33.3",
68
- "@meith/messages": "0.33.3",
69
- "@meith/moderation": "0.33.3",
70
- "@meith/notifications": "0.33.3",
71
- "@meith/plugin-calendar": "0.33.3",
72
- "@meith/plugin-dues": "0.33.3",
73
- "@meith/plugin-kit": "0.33.3",
74
- "@meith/polls": "0.33.3",
75
- "@meith/posts": "0.33.3",
76
- "@meith/profile-fields": "0.33.3",
77
- "@meith/reputation": "0.33.3",
78
- "@meith/relations": "0.33.3",
79
- "@meith/runtime": "0.33.3",
80
- "@meith/search": "0.33.3",
81
- "@meith/settings": "0.33.3",
82
- "@meith/signatures": "0.33.3",
83
- "@meith/subscriptions": "0.33.3",
84
- "@meith/tasks": "0.33.3",
85
- "@meith/theme-default": "0.33.3",
86
- "@meith/theme-clubhouse": "0.33.3",
87
- "@meith/theme-kit": "0.33.3",
88
- "@meith/theme-midnight": "0.33.3",
89
- "@meith/theme-phasebook": "0.33.3",
90
- "@meith/theme-raidframe": "0.33.3",
91
- "@meith/threads": "0.33.3",
92
- "@meith/ui": "0.33.3",
93
- "@meith/upgrade": "0.33.3"
46
+ "@meith/accounts": "0.33.4",
47
+ "@meith/admin": "0.33.4",
48
+ "@meith/antispam": "0.33.4",
49
+ "@meith/api": "0.33.4",
50
+ "@meith/attachments": "0.33.4",
51
+ "@meith/authorization": "0.33.4",
52
+ "@meith/avatars": "0.33.4",
53
+ "@meith/board-digest": "0.33.4",
54
+ "@meith/core": "0.33.4",
55
+ "@meith/db": "0.33.4",
56
+ "@meith/demo": "0.33.4",
57
+ "@meith/drivers": "0.33.4",
58
+ "@meith/drafts": "0.33.4",
59
+ "@meith/events": "0.33.4",
60
+ "@meith/forums": "0.33.4",
61
+ "@meith/groups": "0.33.4",
62
+ "@meith/i18n": "0.33.4",
63
+ "@meith/import": "0.33.4",
64
+ "@meith/install": "0.33.4",
65
+ "@meith/mail": "0.33.4",
66
+ "@meith/markdown": "0.33.4",
67
+ "@meith/marketplace": "0.33.4",
68
+ "@meith/messages": "0.33.4",
69
+ "@meith/moderation": "0.33.4",
70
+ "@meith/notifications": "0.33.4",
71
+ "@meith/plugin-calendar": "0.33.4",
72
+ "@meith/plugin-dues": "0.33.4",
73
+ "@meith/plugin-kit": "0.33.4",
74
+ "@meith/polls": "0.33.4",
75
+ "@meith/posts": "0.33.4",
76
+ "@meith/profile-fields": "0.33.4",
77
+ "@meith/relations": "0.33.4",
78
+ "@meith/reputation": "0.33.4",
79
+ "@meith/runtime": "0.33.4",
80
+ "@meith/search": "0.33.4",
81
+ "@meith/settings": "0.33.4",
82
+ "@meith/signatures": "0.33.4",
83
+ "@meith/subscriptions": "0.33.4",
84
+ "@meith/tasks": "0.33.4",
85
+ "@meith/theme-clubhouse": "0.33.4",
86
+ "@meith/theme-default": "0.33.4",
87
+ "@meith/theme-kit": "0.33.4",
88
+ "@meith/theme-midnight": "0.33.4",
89
+ "@meith/theme-phasebook": "0.33.4",
90
+ "@meith/theme-raidframe": "0.33.4",
91
+ "@meith/threads": "0.33.4",
92
+ "@meith/ui": "0.33.4",
93
+ "@meith/upgrade": "0.33.4"
94
94
  },
95
95
  "scripts": {
96
96
  "dev": "next dev",
@@ -11,7 +11,8 @@ import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
11
11
  const BUTTON =
12
12
  'inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
13
13
  const FIELD =
14
- 'h-8 rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
14
+ 'h-8 min-w-0 max-w-full rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
15
+ const LABEL = 'flex min-w-0 max-w-full items-center text-xs'
15
16
 
16
17
  export interface SplitPoint {
17
18
  readonly id: number
@@ -39,9 +40,9 @@ export function ThreadSurgeryForm({
39
40
  <form action={splitAction} className="flex flex-col gap-2">
40
41
  <div className="flex flex-wrap items-center gap-2">
41
42
  <input type="hidden" name="threadId" value={threadId} />
42
- <label className="flex items-center gap-2 text-xs">
43
+ <label className={LABEL}>
43
44
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.fromSr')}</span>
44
- <select name="fromPostId" className={FIELD}>
45
+ <select name="fromPostId" className={`${FIELD} sm:max-w-64`}>
45
46
  {splitPoints.map((point) => (
46
47
  <option key={point.id} value={point.id}>
47
48
  {formatFromCopy(copy, 'moderationForm.surgery.splitPoint', {
@@ -52,7 +53,7 @@ export function ThreadSurgeryForm({
52
53
  ))}
53
54
  </select>
54
55
  </label>
55
- <label className="flex items-center gap-2 text-xs">
56
+ <label className={LABEL}>
56
57
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.titleSr')}</span>
57
58
  <input
58
59
  type="text"
@@ -76,7 +77,7 @@ export function ThreadSurgeryForm({
76
77
  <form action={mergeAction} className="flex flex-col gap-2">
77
78
  <div className="flex flex-wrap items-center gap-2">
78
79
  <input type="hidden" name="threadId" value={threadId} />
79
- <label className="flex items-center gap-2 text-xs">
80
+ <label className={LABEL}>
80
81
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.mergeSr')}</span>
81
82
  <input
82
83
  type="number"
@@ -11,6 +11,27 @@ import { type Copy, fromCopy } from '../shell/copy'
11
11
 
12
12
  const BUTTON =
13
13
  'inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
14
+ const SELECT =
15
+ 'h-8 min-w-0 max-w-full rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
16
+ const SUMMARY =
17
+ 'flex cursor-pointer list-none items-center gap-2 rounded-lg px-4 py-3 text-xs font-medium text-muted-foreground select-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden'
18
+
19
+ function Chevron() {
20
+ return (
21
+ <svg
22
+ aria-hidden="true"
23
+ viewBox="0 0 12 12"
24
+ className="size-3 shrink-0 transition-transform group-open/tools:rotate-90"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ strokeWidth="1.5"
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ >
31
+ <path d="m4.5 3 3 3-3 3" />
32
+ </svg>
33
+ )
34
+ }
14
35
 
15
36
  export interface MoveOption {
16
37
  readonly id: number
@@ -25,6 +46,7 @@ export function ThreadToolsForm({
25
46
  moveTargets,
26
47
  heading,
27
48
  copy,
49
+ open = false,
28
50
  children,
29
51
  }: {
30
52
  threadId: number
@@ -34,74 +56,75 @@ export function ThreadToolsForm({
34
56
  moveTargets: readonly MoveOption[]
35
57
  heading: string
36
58
  copy: Copy
59
+ open?: boolean
37
60
  children?: React.ReactNode
38
61
  }) {
39
62
  const [state, action] = useActionState(threadToolAction, EMPTY_STATE)
40
63
 
41
64
  return (
42
- <section
43
- aria-label={heading}
44
- className="flex flex-col gap-2 rounded-lg border border-border bg-secondary px-4 py-3"
45
- >
46
- <div className="flex flex-wrap items-center gap-3">
47
- <span className="text-xs font-medium text-muted-foreground">{heading}</span>
48
-
49
- <form action={action} className="flex flex-wrap items-center gap-3">
50
- <input type="hidden" name="threadId" value={threadId} />
65
+ <section aria-label={heading} className="rounded-lg border border-border bg-secondary">
66
+ <details className="group/tools" open={open || state.error !== undefined ? true : undefined}>
67
+ <summary className={SUMMARY}>
68
+ <Chevron />
69
+ {heading}
70
+ </summary>
51
71
 
52
- {rights.lock && (
53
- <PendingButton name="tool" value={isLocked ? 'unlock' : 'lock'} className={BUTTON}>
54
- {isLocked
55
- ? fromCopy(copy, 'moderationForm.tool.unlock')
56
- : fromCopy(copy, 'moderationForm.tool.lock')}
57
- </PendingButton>
58
- )}
59
- {rights.stick && (
60
- <PendingButton name="tool" value={isSticky ? 'unstick' : 'stick'} className={BUTTON}>
61
- {isSticky
62
- ? fromCopy(copy, 'moderationForm.tool.unpin')
63
- : fromCopy(copy, 'moderationForm.tool.pin')}
64
- </PendingButton>
65
- )}
66
- {rights.delete && (
67
- <PendingButton
68
- name="tool"
69
- value="delete"
70
- className={`${BUTTON} border-destructive/40 text-destructive`}
71
- >
72
- {fromCopy(copy, 'moderationForm.tool.deleteThread')}
73
- </PendingButton>
74
- )}
72
+ <div className="flex flex-col gap-2 px-4 pb-3">
73
+ <form action={action} className="flex flex-wrap items-center gap-2">
74
+ <input type="hidden" name="threadId" value={threadId} />
75
75
 
76
- {rights.move && moveTargets.length > 0 && (
77
- <span className="flex items-center gap-2">
78
- <label className="flex items-center gap-2 text-xs">
79
- <span className="sr-only">{fromCopy(copy, 'moderationForm.moveTo')}</span>
80
- <select
81
- name="toForumId"
82
- className="h-8 rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
83
- >
84
- {moveTargets.map((forum) => (
85
- <option key={forum.id} value={forum.id}>
86
- {forum.title}
87
- </option>
88
- ))}
89
- </select>
90
- </label>
91
- <PendingButton name="tool" value="move" className={BUTTON}>
92
- {fromCopy(copy, 'moderationForm.tool.move')}
76
+ {rights.lock && (
77
+ <PendingButton name="tool" value={isLocked ? 'unlock' : 'lock'} className={BUTTON}>
78
+ {isLocked
79
+ ? fromCopy(copy, 'moderationForm.tool.unlock')
80
+ : fromCopy(copy, 'moderationForm.tool.lock')}
81
+ </PendingButton>
82
+ )}
83
+ {rights.stick && (
84
+ <PendingButton name="tool" value={isSticky ? 'unstick' : 'stick'} className={BUTTON}>
85
+ {isSticky
86
+ ? fromCopy(copy, 'moderationForm.tool.unpin')
87
+ : fromCopy(copy, 'moderationForm.tool.pin')}
93
88
  </PendingButton>
94
- <PendingButton name="tool" value="copy" className={BUTTON}>
95
- {fromCopy(copy, 'moderationForm.tool.copy')}
89
+ )}
90
+ {rights.delete && (
91
+ <PendingButton
92
+ name="tool"
93
+ value="delete"
94
+ className={`${BUTTON} border-destructive/40 text-destructive`}
95
+ >
96
+ {fromCopy(copy, 'moderationForm.tool.deleteThread')}
96
97
  </PendingButton>
97
- </span>
98
- )}
99
- </form>
98
+ )}
99
+
100
+ {rights.move && moveTargets.length > 0 && (
101
+ <span className="flex min-w-0 max-w-full basis-full flex-wrap items-center gap-2 sm:basis-auto">
102
+ <label className="flex min-w-0 max-w-full flex-1 items-center text-xs sm:flex-none">
103
+ <span className="sr-only">{fromCopy(copy, 'moderationForm.moveTo')}</span>
104
+ <select name="toForumId" className={`${SELECT} w-full sm:w-auto sm:max-w-56`}>
105
+ {moveTargets.map((forum) => (
106
+ <option key={forum.id} value={forum.id}>
107
+ {forum.title}
108
+ </option>
109
+ ))}
110
+ </select>
111
+ </label>
112
+ <PendingButton name="tool" value="move" className={BUTTON}>
113
+ {fromCopy(copy, 'moderationForm.tool.move')}
114
+ </PendingButton>
115
+ <PendingButton name="tool" value="copy" className={BUTTON}>
116
+ {fromCopy(copy, 'moderationForm.tool.copy')}
117
+ </PendingButton>
118
+ </span>
119
+ )}
120
+ </form>
121
+
122
+ {children}
100
123
 
101
- {children}
102
- </div>
124
+ <FormError message={state.error} />
125
+ </div>
126
+ </details>
103
127
 
104
- <FormError message={state.error} />
105
128
  <ConfirmDialog confirm={state.confirm} action={action} />
106
129
  </section>
107
130
  )
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ const PEEKING_HEADER = '[data-header-peek]'
6
+
7
+ const SETTLE = 8
8
+
9
+ function peek(header: HTMLElement): () => void {
10
+ let last = window.scrollY
11
+ let ticking = false
12
+
13
+ function settle() {
14
+ ticking = false
15
+ const y = Math.max(0, window.scrollY)
16
+ const delta = y - last
17
+
18
+ if (y <= header.offsetHeight) {
19
+ header.removeAttribute('data-peek')
20
+ } else if (delta > SETTLE) {
21
+ header.setAttribute('data-peek', '')
22
+ } else if (delta < -SETTLE) {
23
+ header.removeAttribute('data-peek')
24
+ } else {
25
+ return
26
+ }
27
+ last = y
28
+ }
29
+
30
+ function onScroll() {
31
+ if (ticking) return
32
+ ticking = true
33
+ window.requestAnimationFrame(settle)
34
+ }
35
+
36
+ function onFocusIn() {
37
+ header.removeAttribute('data-peek')
38
+ }
39
+
40
+ window.addEventListener('scroll', onScroll, { passive: true })
41
+ header.addEventListener('focusin', onFocusIn)
42
+
43
+ return () => {
44
+ window.removeEventListener('scroll', onScroll)
45
+ header.removeEventListener('focusin', onFocusIn)
46
+ header.removeAttribute('data-peek')
47
+ }
48
+ }
49
+
50
+ export function HeaderPeekEnhancer() {
51
+ useEffect(() => {
52
+ const header = document.querySelector<HTMLElement>(PEEKING_HEADER)
53
+ if (header === null) return
54
+ return peek(header)
55
+ }, [])
56
+
57
+ return null
58
+ }
@@ -3,6 +3,7 @@ import { currentRequestId } from '@meith/core/logger'
3
3
  import { requireSlot, slotCopy } from '@meith/theme-kit'
4
4
 
5
5
  import { LogoutForm } from '@/components/account/logout-form'
6
+ import { HeaderPeekEnhancer } from '@/components/shell/header-peek-enhancer'
6
7
  import { InstallBanner } from '@/components/shell/install-banner'
7
8
  import { NavDisclosureEnhancer } from '@/components/shell/nav-disclosure-enhancer'
8
9
  import { NotificationMenu } from '@/components/shell/notification-menu'
@@ -154,6 +155,7 @@ export async function PageShell({ actor, children }: { actor: Actor; children: R
154
155
  </Header>
155
156
 
156
157
  <NavDisclosureEnhancer />
158
+ <HeaderPeekEnhancer />
157
159
 
158
160
  {await boardRegion('header.notice', actor)}
159
161
 
@@ -17,7 +17,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
17
17
 
18
18
  import { activeDefinitions } from './plugin-host'
19
19
 
20
- export const CODE_VERSION = '0.33.3'
20
+ export const CODE_VERSION = '0.33.4'
21
21
 
22
22
  export interface UpgradeApplied {
23
23
  readonly plugins: readonly string[]
@@ -486,11 +486,11 @@
486
486
  * A permalink to `#post-12` otherwise lands the post flush against the top
487
487
  * of the viewport, with its own header cropped and the previous post's footer
488
488
  * nowhere — the reader cannot tell they arrived at the right one. The
489
- * default theme's header is sticky 3.5rem on a phone, 6.25rem with its
490
- * navigation row on a desktop — so the margin clears it with room to spare.
489
+ * default theme's header is sticky and 3.5rem tall, so the margin clears it
490
+ * with room to spare.
491
491
  */
492
492
  :target {
493
- scroll-margin-block-start: 7.5rem;
493
+ scroll-margin-block-start: 5rem;
494
494
  }
495
495
 
496
496
  ::selection {
@@ -22,27 +22,36 @@ export interface BuiltInNavigationItem {
22
22
  readonly messageKey: string
23
23
  readonly href: string
24
24
  readonly audience: NavigationAudience
25
+ readonly shown: boolean
25
26
  }
26
27
 
27
28
  export const BUILT_IN_NAVIGATION: readonly BuiltInNavigationItem[] = [
28
- { key: 'home', messageKey: 'nav.home', href: '/', audience: 'all' },
29
- { key: 'new-posts', messageKey: 'nav.newPosts', href: '/discover/new', audience: 'all' },
29
+ { key: 'home', messageKey: 'nav.home', href: '/', audience: 'all', shown: true },
30
+ {
31
+ key: 'new-posts',
32
+ messageKey: 'nav.newPosts',
33
+ href: '/discover/new',
34
+ audience: 'all',
35
+ shown: true,
36
+ },
30
37
  {
31
38
  key: 'unanswered',
32
39
  messageKey: 'nav.unanswered',
33
40
  href: '/discover/unanswered',
34
41
  audience: 'all',
42
+ shown: false,
35
43
  },
36
44
  {
37
45
  key: 'my-posts',
38
46
  messageKey: 'nav.myPosts',
39
47
  href: '/discover/participated',
40
48
  audience: 'members',
49
+ shown: false,
41
50
  },
42
- { key: 'search', messageKey: 'nav.search', href: '/search', audience: 'all' },
43
- { key: 'online', messageKey: 'nav.online', href: '/online', audience: 'all' },
44
- { key: 'members', messageKey: 'nav.members', href: '/members', audience: 'all' },
45
- { key: 'staff', messageKey: 'nav.staff', href: '/staff', audience: 'all' },
51
+ { key: 'search', messageKey: 'nav.search', href: '/search', audience: 'all', shown: true },
52
+ { key: 'online', messageKey: 'nav.online', href: '/online', audience: 'all', shown: false },
53
+ { key: 'members', messageKey: 'nav.members', href: '/members', audience: 'all', shown: false },
54
+ { key: 'staff', messageKey: 'nav.staff', href: '/staff', audience: 'all', shown: false },
46
55
  ]
47
56
 
48
57
  const SEARCH_KEY = 'search'
@@ -64,7 +73,7 @@ export function defaultNavigationItems(): readonly NavigationItemRow[] {
64
73
  displayOrder: index * 10,
65
74
  audience: item.audience,
66
75
  newTab: false,
67
- enabled: true,
76
+ enabled: item.shown,
68
77
  visibleToGroups: [],
69
78
  }))
70
79
  }