@meith/theme-phasebook 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/theme-phasebook",
3
- "version": "0.30.1",
3
+ "version": "0.32.0",
4
4
  "description": "A theme for Meith with a familiar social shape, built on the default theme's slots.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,9 +20,9 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/theme-default": "^0.30.1",
24
- "@meith/theme-kit": "^0.30.1",
25
- "@meith/ui": "^0.30.1"
23
+ "@meith/theme-default": "^0.32.0",
24
+ "@meith/theme-kit": "^0.32.0",
25
+ "@meith/ui": "^0.32.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": "^19.2.0"
package/src/copy.ts CHANGED
@@ -245,6 +245,8 @@ export function threadViewCopy(t: Translator): SlotCopy {
245
245
  'phasebook.threadView.replyAction',
246
246
  'phasebook.threadView.view.one',
247
247
  'phasebook.threadView.view.other',
248
+ 'phasebook.threadView.watch',
249
+ 'phasebook.threadView.watching',
248
250
  ])
249
251
  }
250
252
 
@@ -129,6 +129,8 @@
129
129
  "phasebook.threadView.replyAction": "Reply",
130
130
  "phasebook.threadView.view.one": "view",
131
131
  "phasebook.threadView.view.other": "views",
132
+ "phasebook.threadView.watch": "Watch",
133
+ "phasebook.threadView.watching": "Watching",
132
134
  "phasebook.userPanel.accountAriaLabel": "Your account",
133
135
  "phasebook.userPanel.messages": "Messages",
134
136
  "phasebook.userPanel.notifications": "Notifications",
@@ -2,7 +2,9 @@
2
2
 
3
3
  import {
4
4
  applyEditorTag,
5
+ applyInsertion,
5
6
  type EditorTag,
7
+ type EditorToolbarButtonModel,
6
8
  type EditorToolbarModel,
7
9
  type SlotCopy,
8
10
  } from '@meith/theme-kit'
@@ -12,20 +14,34 @@ const GLYPHS: Readonly<Record<EditorTag, string>> = {
12
14
  italic: 'I',
13
15
  strikethrough: 'S',
14
16
  link: 'Link',
17
+ image: 'Image',
15
18
  quote: '“”',
16
19
  code: '</>',
17
20
  spoiler: 'Spoiler',
18
21
  bulletedList: '•',
19
22
  numberedList: '1.',
23
+ taskList: '☑',
20
24
  heading: 'H',
25
+ table: 'Table',
21
26
  }
22
27
 
23
28
  const BUTTON =
24
29
  'inline-flex h-8 min-w-8 items-center justify-center rounded-full px-2.5 text-xs font-semibold text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-foreground'
25
30
 
26
- function runTag(textareaId: string, tag: EditorTag, placeholder: string | null): void {
31
+ function runButton(textareaId: string, button: EditorToolbarButtonModel): void {
27
32
  const field = document.getElementById(textareaId)
28
- if (field instanceof HTMLTextAreaElement) applyEditorTag(field, tag, placeholder)
33
+ if (!(field instanceof HTMLTextAreaElement)) return
34
+
35
+ if (button.tag !== null) {
36
+ applyEditorTag(field, button.tag, button.placeholder)
37
+ return
38
+ }
39
+
40
+ if (button.insertion !== null) applyInsertion(field, button.insertion)
41
+ }
42
+
43
+ function glyphFor(button: EditorToolbarButtonModel): string {
44
+ return button.tag === null ? button.label.charAt(0).toUpperCase() : GLYPHS[button.tag]
29
45
  }
30
46
 
31
47
  export function EditorToolbar({
@@ -40,18 +56,19 @@ export function EditorToolbar({
40
56
  aria-label={groupLabel}
41
57
  className="flex flex-wrap gap-1 border-b border-border bg-secondary/40 px-3 py-2"
42
58
  >
43
- {buttons.map((button) => (
59
+ {buttons.map((button, index) => (
44
60
  <button
45
- key={button.tag}
61
+ // biome-ignore lint/suspicious/noArrayIndexKey: a plugin's button carries no id of its own, so the index disambiguates two sharing a tag or label; the list is server-rendered in order and never reordered on the client
62
+ key={`${button.tag ?? button.label}-${index}`}
46
63
  type="button"
47
64
  onMouseDown={(event) => event.preventDefault()}
48
- onClick={() => runTag(textareaId, button.tag, button.placeholder)}
65
+ onClick={() => runButton(textareaId, button)}
49
66
  title={button.title}
50
67
  aria-label={button.label}
51
68
  {...(button.keyShortcut === null ? {} : { 'aria-keyshortcuts': button.keyShortcut })}
52
69
  className={BUTTON}
53
70
  >
54
- <span aria-hidden="true">{GLYPHS[button.tag]}</span>
71
+ <span aria-hidden="true">{glyphFor(button)}</span>
55
72
  </button>
56
73
  ))}
57
74
 
@@ -46,7 +46,7 @@ function Submenu({ items }: { items: HeaderModel['navigation'][number]['submenu'
46
46
  <a
47
47
  href={child.href}
48
48
  {...linkTarget(child)}
49
- className="block px-4 py-2 text-muted-foreground hover:bg-accent hover:text-foreground"
49
+ className="flex items-center px-4 py-2 text-muted-foreground pointer-coarse:min-h-11 hover:bg-accent hover:text-foreground"
50
50
  >
51
51
  {child.label}
52
52
  </a>
@@ -56,6 +56,71 @@ function Submenu({ items }: { items: HeaderModel['navigation'][number]['submenu'
56
56
  )
57
57
  }
58
58
 
59
+ function Chevron({ className }: { className: string }) {
60
+ return (
61
+ <svg
62
+ aria-hidden="true"
63
+ viewBox="0 0 12 12"
64
+ className={className}
65
+ fill="none"
66
+ stroke="currentColor"
67
+ strokeWidth="1.5"
68
+ strokeLinecap="round"
69
+ strokeLinejoin="round"
70
+ >
71
+ <path d="m4.5 3 3 3-3 3" />
72
+ </svg>
73
+ )
74
+ }
75
+
76
+ function MobileNavItem({ item }: { item: HeaderModel['navigation'][number] }) {
77
+ if (item.submenu === undefined || item.submenu.length === 0) {
78
+ return (
79
+ <li>
80
+ <a
81
+ href={item.href}
82
+ {...linkTarget(item)}
83
+ className="flex min-h-11 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
84
+ >
85
+ {item.label}
86
+ </a>
87
+ </li>
88
+ )
89
+ }
90
+
91
+ return (
92
+ <li>
93
+ <details data-nav-disclosure name="phasebook-header-submenu" className="group/submenu">
94
+ <summary className="flex min-h-11 cursor-default list-none items-center justify-between gap-2 rounded-lg px-3 text-sm font-medium text-muted-foreground select-none [&::-webkit-details-marker]:hidden [&::marker]:content-none hover:bg-accent hover:text-foreground">
95
+ <a
96
+ href={item.href}
97
+ {...linkTarget(item)}
98
+ className="min-w-0 flex-1 truncate hover:underline"
99
+ >
100
+ {item.label}
101
+ </a>
102
+ <span className="flex size-11 shrink-0 items-center justify-center">
103
+ <Chevron className="size-3 text-muted-foreground transition-transform group-open/submenu:rotate-90" />
104
+ </span>
105
+ </summary>
106
+ <ul className="ml-2 flex flex-col gap-0.5 border-l border-border py-1 pl-2.5">
107
+ {item.submenu.map((child) => (
108
+ <li key={child.href}>
109
+ <a
110
+ href={child.href}
111
+ {...linkTarget(child)}
112
+ className="flex min-h-11 items-center rounded-lg px-3 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
113
+ >
114
+ {child.label}
115
+ </a>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ </details>
120
+ </li>
121
+ )
122
+ }
123
+
59
124
  export function Header({
60
125
  boardTitle,
61
126
  homeHref,
@@ -66,8 +131,6 @@ export function Header({
66
131
  }: HeaderModel & { copy: SlotCopy }) {
67
132
  const c = (key: string) => fromSlotCopy(copy, `phasebook.header.${key}`)
68
133
 
69
- const opensMenus = navigation.some((item) => item.submenu !== undefined)
70
-
71
134
  return (
72
135
  <header className="sticky top-0 z-30 border-b border-border bg-card shadow-elevation">
73
136
  <div className={`${PAGE} flex h-14 items-center justify-between gap-3`}>
@@ -84,6 +147,7 @@ export function Header({
84
147
  {navigation.length > 0 && (
85
148
  <nav
86
149
  aria-label={c('sectionsAriaLabel')}
150
+ data-nav-view="desktop"
87
151
  className="hidden min-w-0 flex-1 justify-center lg:flex"
88
152
  >
89
153
  <ul className="flex items-center gap-1">
@@ -107,25 +171,24 @@ export function Header({
107
171
  </div>
108
172
 
109
173
  {navigation.length > 0 && (
110
- <nav aria-label={c('sectionsAriaLabel')} className="border-t border-border lg:hidden">
111
- <ul
112
- className={`${PAGE} flex items-center gap-1 py-1 ${
113
- opensMenus ? 'flex-wrap' : 'overflow-x-auto'
114
- }`}
115
- >
116
- {navigation.map((item) => (
117
- <li key={item.href} className="group relative shrink-0">
118
- <a
119
- href={item.href}
120
- {...linkTarget(item)}
121
- className="inline-flex h-9 items-center rounded-full px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
122
- >
123
- {item.label}
124
- </a>
125
- <Submenu items={item.submenu} />
126
- </li>
127
- ))}
128
- </ul>
174
+ <nav
175
+ aria-label={c('sectionsAriaLabel')}
176
+ data-nav-view="mobile"
177
+ className="border-t border-border lg:hidden"
178
+ >
179
+ <div className={`${PAGE} py-1`}>
180
+ <details data-nav-disclosure className="group">
181
+ <summary className="flex min-h-11 cursor-default list-none items-center gap-2 text-sm font-medium text-foreground select-none [&::-webkit-details-marker]:hidden [&::marker]:content-none">
182
+ <Chevron className="size-3 shrink-0 transition-transform group-open:rotate-90" />
183
+ {c('sectionsAriaLabel')}
184
+ </summary>
185
+ <ul className="flex flex-col gap-0.5 pb-2">
186
+ {navigation.map((item) => (
187
+ <MobileNavItem key={item.href} item={item} />
188
+ ))}
189
+ </ul>
190
+ </details>
191
+ </div>
129
192
  </nav>
130
193
  )}
131
194
  </header>
@@ -3,7 +3,12 @@ import { fromSlotCopy } from '@meith/theme-kit'
3
3
 
4
4
  import { Circle, count, MUTED_LINK, NUMERIC, Prefix, plural, Stamp, Tag, UserRef } from '../shared'
5
5
 
6
- export function ThreadRow({ thread, select, copy }: ThreadRowSlotModel & { copy: SlotCopy }) {
6
+ export function ThreadRow({
7
+ thread,
8
+ select,
9
+ regions,
10
+ copy,
11
+ }: ThreadRowSlotModel & { copy: SlotCopy }) {
7
12
  const c = (key: string) => fromSlotCopy(copy, `phasebook.threadRow.${key}`)
8
13
 
9
14
  const hidden = thread.visibility === 'deleted' || thread.visibility === 'unapproved'
@@ -49,6 +54,7 @@ export function ThreadRow({ thread, select, copy }: ThreadRowSlotModel & { copy:
49
54
  <Tag token="thread-unapproved">{c('unapproved')}</Tag>
50
55
  )}
51
56
  {thread.visibility === 'deleted' && <Tag token="thread-deleted">{c('deleted')}</Tag>}
57
+ {regions?.pluginBadges}
52
58
  </div>
53
59
 
54
60
  <a
@@ -19,6 +19,7 @@ export function ThreadView({
19
19
  forum,
20
20
  replyHref,
21
21
  markReadAction,
22
+ watch,
22
23
  regions,
23
24
  copy,
24
25
  }: ThreadViewModel & { copy: SlotCopy }) {
@@ -54,6 +55,19 @@ export function ThreadView({
54
55
  </div>
55
56
 
56
57
  <div className="flex shrink-0 items-center gap-2">
58
+ {watch != null && (
59
+ <form action={watch.action} method="post">
60
+ <button type="submit" className={PILL}>
61
+ {watch.subscribed ? (
62
+ <>
63
+ {c('watching')} <span aria-hidden="true">✓</span>
64
+ </>
65
+ ) : (
66
+ c('watch')
67
+ )}
68
+ </button>
69
+ </form>
70
+ )}
57
71
  {markReadAction !== null && (
58
72
  <form action={markReadAction} method="post">
59
73
  <button type="submit" className={PILL}>
package/src/theme.ts CHANGED
@@ -70,7 +70,7 @@ import { WhoIsOnline } from './slots/who-is-online'
70
70
  export const phasebookTheme = defineTheme({
71
71
  key: 'phasebook',
72
72
  title: 'Phasebook',
73
- version: '0.30.1',
73
+ version: '0.32.0',
74
74
  extends: defaultTheme,
75
75
  slots: {
76
76
  Shell,