@meith/theme-phasebook 0.6.0 → 0.8.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.
@@ -1,5 +1,6 @@
1
- import { Disclosure, cn } from '@meith/ui'
2
- import type { PanelNavItemModel, PanelNavModel } from '@meith/theme-kit'
1
+ import type { PanelNavItemModel, PanelNavModel, SlotCopy } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
3
+ import { cn, Disclosure } from '@meith/ui'
3
4
 
4
5
  import { Chip } from '../shared'
5
6
 
@@ -10,14 +11,24 @@ const HERE = 'bg-primary/12 text-primary'
10
11
  const OPEN = 'text-foreground hover:bg-accent'
11
12
  const ELSEWHERE = 'text-muted-foreground hover:bg-accent hover:text-foreground'
12
13
 
13
- function Item({ item, className }: { item: PanelNavItemModel; className: string }) {
14
+ function Item({
15
+ item,
16
+ className,
17
+ copy,
18
+ }: {
19
+ item: PanelNavItemModel
20
+ className: string
21
+ copy: SlotCopy
22
+ }) {
23
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.panelNav.${key}`)
24
+
14
25
  const body = (
15
26
  <>
16
27
  <span className="min-w-0 flex-1 truncate">{item.title}</span>
17
28
  {item.count !== null && (
18
29
  <Chip className="bg-primary/12 text-primary">
19
30
  {item.count > 99 ? '99+' : item.count}
20
- <span className="sr-only"> waiting</span>
31
+ <span className="sr-only"> {c('waiting')}</span>
21
32
  </Chip>
22
33
  )}
23
34
  </>
@@ -44,7 +55,11 @@ function Item({ item, className }: { item: PanelNavItemModel; className: string
44
55
  )
45
56
  }
46
57
 
47
- function Sections({ label, sections }: Pick<PanelNavModel, 'label' | 'sections'>) {
58
+ function Sections({
59
+ label,
60
+ sections,
61
+ copy,
62
+ }: Pick<PanelNavModel, 'label' | 'sections'> & { copy: SlotCopy }) {
48
63
  return (
49
64
  <nav aria-label={label}>
50
65
  <ul className="flex flex-col">
@@ -56,6 +71,7 @@ function Sections({ label, sections }: Pick<PanelNavModel, 'label' | 'sections'>
56
71
  <Item
57
72
  item={section}
58
73
  className={section.current === 'here' ? HERE : section.isOpen ? OPEN : ELSEWHERE}
74
+ copy={copy}
59
75
  />
60
76
 
61
77
  {section.isOpen && section.children.length > 0 && (
@@ -68,6 +84,7 @@ function Sections({ label, sections }: Pick<PanelNavModel, 'label' | 'sections'>
68
84
  'text-[0.8125rem]',
69
85
  child.current === 'here' ? HERE : ELSEWHERE,
70
86
  )}
87
+ copy={copy}
71
88
  />
72
89
  </li>
73
90
  ))}
@@ -80,7 +97,12 @@ function Sections({ label, sections }: Pick<PanelNavModel, 'label' | 'sections'>
80
97
  )
81
98
  }
82
99
 
83
- export function PanelNav({ label, sections, currentTitle }: PanelNavModel) {
100
+ export function PanelNav({
101
+ label,
102
+ sections,
103
+ currentTitle,
104
+ copy,
105
+ }: PanelNavModel & { copy: SlotCopy }) {
84
106
  return (
85
107
  <>
86
108
  <Disclosure
@@ -89,11 +111,11 @@ export function PanelNav({ label, sections, currentTitle }: PanelNavModel) {
89
111
  contentClassName="p-2"
90
112
  {...(currentTitle === null ? {} : { aside: currentTitle })}
91
113
  >
92
- <Sections label={label} sections={sections} />
114
+ <Sections label={label} sections={sections} copy={copy} />
93
115
  </Disclosure>
94
116
 
95
117
  <div className="hidden rounded-lg border border-border bg-card p-2 shadow-elevation lg:block">
96
- <Sections label={label} sections={sections} />
118
+ <Sections label={label} sections={sections} copy={copy} />
97
119
  </div>
98
120
  </>
99
121
  )
@@ -1,5 +1,5 @@
1
- import { cn } from '@meith/ui'
2
1
  import type { PanelPageModel, PanelSectionModel } from '@meith/theme-kit'
2
+ import { cn } from '@meith/ui'
3
3
 
4
4
  import { MUTED_LINK } from '../shared'
5
5
 
@@ -29,9 +29,7 @@ export function PanelPage({ title, back, frame, width, gap, regions, children }:
29
29
  </a>
30
30
  )}
31
31
 
32
- <h1 className="text-xl leading-tight font-bold tracking-tight text-balance">
33
- {title}
34
- </h1>
32
+ <h1 className="text-xl leading-tight font-bold tracking-tight text-balance">{title}</h1>
35
33
 
36
34
  {regions.lede !== undefined && (
37
35
  <p className="text-sm text-muted-foreground">{regions.lede}</p>
@@ -57,10 +55,7 @@ export function PanelSection({ title, headingId, regions, children }: PanelSecti
57
55
  <section aria-labelledby={headingId} className="flex flex-col gap-3">
58
56
  <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-1 px-1">
59
57
  <div className="flex min-w-0 flex-col gap-0.5">
60
- <h2
61
- id={headingId}
62
- className="text-[0.9375rem] font-semibold text-muted-foreground"
63
- >
58
+ <h2 id={headingId} className="text-[0.9375rem] font-semibold text-muted-foreground">
64
59
  {title}
65
60
  </h2>
66
61
  {regions.description !== undefined && (
@@ -1,4 +1,5 @@
1
- import type { PostActionsSlotModel } from '@meith/theme-kit'
1
+ import type { PostActionsSlotModel, SlotCopy } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
2
3
 
3
4
  interface Action {
4
5
  readonly href: string
@@ -9,24 +10,30 @@ const ACTION =
9
10
  'inline-flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold ' +
10
11
  'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
11
12
 
12
- export function PostActions({ actions, children }: PostActionsSlotModel) {
13
+ export function PostActions({
14
+ actions,
15
+ children,
16
+ copy,
17
+ }: PostActionsSlotModel & { copy: SlotCopy }) {
18
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.postActions.${key}`)
19
+
13
20
  const reader: Action[] = [
14
- actions.quoteHref === null ? null : { href: actions.quoteHref, label: 'Quote' },
15
- actions.editHref === null ? null : { href: actions.editHref, label: 'Edit' },
16
- actions.rateHref === null ? null : { href: actions.rateHref, label: 'Rate' },
17
- actions.reportHref === null ? null : { href: actions.reportHref, label: 'Report' },
21
+ actions.quoteHref === null ? null : { href: actions.quoteHref, label: c('quote') },
22
+ actions.editHref === null ? null : { href: actions.editHref, label: c('edit') },
23
+ actions.rateHref === null ? null : { href: actions.rateHref, label: c('rate') },
24
+ actions.reportHref === null ? null : { href: actions.reportHref, label: c('report') },
18
25
  ].filter((action): action is Action => action !== null)
19
26
 
20
27
  const staff: Action[] = [
21
- actions.restoreHref === null ? null : { href: actions.restoreHref, label: 'Restore' },
22
- actions.warnHref === null ? null : { href: actions.warnHref, label: 'Warn' },
23
- actions.moderateHref === null ? null : { href: actions.moderateHref, label: 'Moderate' },
28
+ actions.restoreHref === null ? null : { href: actions.restoreHref, label: c('restore') },
29
+ actions.warnHref === null ? null : { href: actions.warnHref, label: c('warn') },
30
+ actions.moderateHref === null ? null : { href: actions.moderateHref, label: c('moderate') },
24
31
  ].filter((action): action is Action => action !== null)
25
32
 
26
33
  if (reader.length === 0 && staff.length === 0 && children === undefined) return null
27
34
 
28
35
  return (
29
- <nav aria-label="Post actions" className="flex flex-wrap items-center gap-1">
36
+ <nav aria-label={c('ariaLabel')} className="flex flex-wrap items-center gap-1">
30
37
  {reader.map((action) => (
31
38
  <a key={action.href} href={action.href} className={ACTION}>
32
39
  {action.label}
@@ -1,17 +1,18 @@
1
+ import type { PostBitSlotModel, SlotCopy } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
1
3
  import { cn } from '@meith/ui'
2
- import type { PostBitSlotModel } from '@meith/theme-kit'
3
4
 
4
5
  import {
5
6
  Circle,
7
+ count,
6
8
  LINK,
7
9
  MUTED_LINK,
8
10
  NUMERIC,
9
11
  OnlineDot,
12
+ plural,
10
13
  Stamp,
11
14
  Tag,
12
15
  UserRef,
13
- count,
14
- plural,
15
16
  } from '../shared'
16
17
 
17
18
  type Post = PostBitSlotModel['post']
@@ -22,10 +23,11 @@ const VISIBILITY_TINT = {
22
23
  deleted: 'border-thread-deleted/50 bg-destructive/5',
23
24
  } as const
24
25
 
25
- function StatusBanner({ visibility }: { visibility: Post['visibility'] }) {
26
+ function StatusBanner({ visibility, copy }: { visibility: Post['visibility']; copy: SlotCopy }) {
26
27
  if (visibility === 'visible') return null
27
28
 
28
29
  const deleted = visibility === 'deleted'
30
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.postBit.${key}`)
29
31
 
30
32
  return (
31
33
  <p
@@ -36,8 +38,8 @@ function StatusBanner({ visibility }: { visibility: Post['visibility'] }) {
36
38
  : 'border-thread-unapproved/30 text-thread-unapproved',
37
39
  )}
38
40
  >
39
- {deleted ? 'Deleted post.' : 'Waiting for approval.'}
40
- <span className="font-normal text-muted-foreground">Only moderators can see this.</span>
41
+ {deleted ? c('deletedPost') : c('waitingApproval')}
42
+ <span className="font-normal text-muted-foreground">{c('moderatorsOnly')}</span>
41
43
  </p>
42
44
  )
43
45
  }
@@ -114,8 +116,9 @@ function Attachments({ files }: { files: Post['attachments'] }) {
114
116
  )
115
117
  }
116
118
 
117
- export function PostBit({ post, select, regions }: PostBitSlotModel) {
119
+ export function PostBit({ post, select, regions, copy }: PostBitSlotModel & { copy: SlotCopy }) {
118
120
  const { author } = post
121
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.postBit.${key}`)
119
122
 
120
123
  return (
121
124
  <article
@@ -127,7 +130,7 @@ export function PostBit({ post, select, regions }: PostBitSlotModel) {
127
130
  VISIBILITY_TINT[post.visibility],
128
131
  )}
129
132
  >
130
- <StatusBanner visibility={post.visibility} />
133
+ <StatusBanner visibility={post.visibility} copy={copy} />
131
134
 
132
135
  <header className="flex items-start gap-3.5 px-5 pt-4">
133
136
  {select !== null && (
@@ -164,7 +167,7 @@ export function PostBit({ post, select, regions }: PostBitSlotModel) {
164
167
  {author.isOnline && (
165
168
  <>
166
169
  <span aria-hidden="true">·</span>
167
- <span className="text-moderation-approved">Online</span>
170
+ <span className="text-moderation-approved">{c('online')}</span>
168
171
  </>
169
172
  )}
170
173
  </p>
@@ -176,12 +179,14 @@ export function PostBit({ post, select, regions }: PostBitSlotModel) {
176
179
  )}
177
180
  </div>
178
181
 
179
- <p className={`shrink-0 text-right text-xs leading-relaxed text-muted-foreground ${NUMERIC}`}>
180
- {count(author.postCount)} {plural(author.postCount, 'post', 'posts')}
182
+ <p
183
+ className={`shrink-0 text-right text-xs leading-relaxed text-muted-foreground ${NUMERIC}`}
184
+ >
185
+ {count(author.postCount)} {plural(author.postCount, c('post.one'), c('post.other'))}
181
186
  {author.reputation != null && (
182
187
  <>
183
188
  <br />
184
- {count(author.reputation)} rep
189
+ {count(author.reputation)} {c('rep')}
185
190
  </>
186
191
  )}
187
192
  </p>
@@ -189,11 +194,11 @@ export function PostBit({ post, select, regions }: PostBitSlotModel) {
189
194
 
190
195
  {post.ignored !== null ? (
191
196
  <p className="px-5 py-5 text-sm text-muted-foreground">
192
- You are ignoring{' '}
193
- <span className="font-semibold text-foreground">{post.ignored.authorUsername}</span>. This
194
- post is hidden.{' '}
197
+ {c('ignoring')}{' '}
198
+ <span className="font-semibold text-foreground">{post.ignored.authorUsername}</span>.{' '}
199
+ {c('hiddenNote')}{' '}
195
200
  <a href={post.ignored.revealHref} className={LINK}>
196
- Show it anyway
201
+ {c('showAnyway')}
197
202
  </a>
198
203
  </p>
199
204
  ) : (
@@ -1,4 +1,5 @@
1
- import type { PostFormModel } from '@meith/theme-kit'
1
+ import type { PostFormModel, SlotCopy } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
2
3
 
3
4
  import { FEED, PAGE, PILL } from '../shared'
4
5
 
@@ -8,7 +9,10 @@ export function PostForm({
8
9
  cancelLabel,
9
10
  errorMessage,
10
11
  regions,
11
- }: PostFormModel) {
12
+ copy,
13
+ }: PostFormModel & { copy: SlotCopy }) {
14
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.postForm.${key}`)
15
+
12
16
  return (
13
17
  <div className={`${PAGE} py-4 sm:py-6`}>
14
18
  <div className={`${FEED} flex flex-col gap-4`}>
@@ -17,7 +21,7 @@ export function PostForm({
17
21
  role="alert"
18
22
  className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm shadow-elevation"
19
23
  >
20
- <span className="font-semibold">Cannot post.</span> {errorMessage}
24
+ <span className="font-semibold">{c('cannotPost')}</span> {errorMessage}
21
25
  </p>
22
26
  )}
23
27
 
@@ -1,21 +1,30 @@
1
- import type { RedirectNoticeModel } from '@meith/theme-kit'
1
+ import type { RedirectNoticeModel, SlotCopy } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
2
3
 
3
4
  import { NUMERIC, PILL_PRIMARY } from '../shared'
4
5
 
5
- export function RedirectNotice({ message, targetHref, delaySeconds }: RedirectNoticeModel) {
6
+ export function RedirectNotice({
7
+ message,
8
+ targetHref,
9
+ delaySeconds,
10
+ copy,
11
+ }: RedirectNoticeModel & { copy: SlotCopy }) {
12
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.redirectNotice.${key}`)
13
+
6
14
  return (
7
15
  <section className="w-full max-w-lg rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
8
16
  <div className="px-5 py-6">
9
- <p className="text-xs font-semibold text-muted-foreground">Redirecting</p>
10
- <h1 className="mt-1 text-2xl leading-tight font-bold tracking-tight">Please wait</h1>
17
+ <p className="text-xs font-semibold text-muted-foreground">{c('redirecting')}</p>
18
+ <h1 className="mt-1 text-2xl leading-tight font-bold tracking-tight">{c('pleaseWait')}</h1>
11
19
  <p className="mt-2 text-sm text-muted-foreground">{message}</p>
12
20
 
13
21
  <div className="mt-5 flex flex-wrap items-center gap-3">
14
22
  <a href={targetHref} className={PILL_PRIMARY}>
15
- Continue now
23
+ {c('continueNow')}
16
24
  </a>
17
25
  <span className={`text-xs text-muted-foreground ${NUMERIC}`}>
18
- Continuing on its own in {delaySeconds} {delaySeconds === 1 ? 'second' : 'seconds'}.
26
+ {c('continuing')} {delaySeconds}{' '}
27
+ {delaySeconds === 1 ? c('second.one') : c('second.other')}.
19
28
  </span>
20
29
  </div>
21
30
  </div>
@@ -1,4 +1,5 @@
1
- import type { OptionModel, SearchAdvancedModel, SearchFormModel } from '@meith/theme-kit'
1
+ import type { OptionModel, SearchAdvancedModel, SearchFormModel, SlotCopy } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
2
3
 
3
4
  import { PILL_PRIMARY } from '../shared'
4
5
 
@@ -102,17 +103,23 @@ export function SearchForm({
102
103
  hint,
103
104
  errorMessage,
104
105
  advanced,
105
- }: SearchFormModel) {
106
- const described = [hint === null ? null : 'search-hint', errorMessage === null ? null : 'search-error']
106
+ copy,
107
+ }: SearchFormModel & { copy: SlotCopy }) {
108
+ const described = [
109
+ hint === null ? null : 'search-hint',
110
+ errorMessage === null ? null : 'search-error',
111
+ ]
107
112
  .filter((id): id is string => id !== null)
108
113
  .join(' ')
109
114
 
115
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.searchForm.${key}`)
116
+
110
117
  return (
111
118
  <section className="rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
112
119
  <form method="get" action={action} className="flex flex-col gap-4 px-4 py-4">
113
120
  <div>
114
121
  <label htmlFor="search-query" className={LABEL}>
115
- Search for
122
+ {c('searchFor')}
116
123
  </label>
117
124
  <input
118
125
  id="search-query"
@@ -121,7 +128,7 @@ export function SearchForm({
121
128
  defaultValue={query}
122
129
  maxLength={maxQueryLength}
123
130
  autoComplete="off"
124
- placeholder="Words in a subject or a post"
131
+ placeholder={c('placeholder')}
125
132
  aria-invalid={errorMessage === null ? undefined : true}
126
133
  {...(described === '' ? {} : { 'aria-describedby': described })}
127
134
  className="w-full rounded-full border border-input bg-surface px-4 py-2.5 text-sm text-foreground transition-colors focus-visible:border-ring"
@@ -141,15 +148,15 @@ export function SearchForm({
141
148
  </div>
142
149
 
143
150
  <div className="grid gap-4 sm:grid-cols-2">
144
- <Choice id="search-forum" label="In" name={fields.forum} options={forums} />
145
- <Choice id="search-sort" label="Sort by" name={fields.sort} options={sorts} />
151
+ <Choice id="search-forum" label={c('in')} name={fields.forum} options={forums} />
152
+ <Choice id="search-sort" label={c('sortBy')} name={fields.sort} options={sorts} />
146
153
  </div>
147
154
 
148
155
  {advanced !== undefined && <Advanced {...advanced} />}
149
156
 
150
157
  <div>
151
158
  <button type="submit" className={PILL_PRIMARY}>
152
- Search
159
+ {c('search')}
153
160
  </button>
154
161
  </div>
155
162
  </form>
@@ -1,4 +1,5 @@
1
- import type { OptionModel, SearchRefineModel, SearchResultsModel } from '@meith/theme-kit'
1
+ import type { OptionModel, SearchRefineModel, SearchResultsModel, SlotCopy } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
2
3
 
3
4
  import { FEED, LINK, PAGE, PILL, PILL_PRIMARY, Stamp } from '../shared'
4
5
 
@@ -12,26 +13,27 @@ export function SearchResults({
12
13
  within,
13
14
  refine,
14
15
  regions,
15
- }: SearchResultsModel) {
16
+ copy,
17
+ }: SearchResultsModel & { copy: SlotCopy }) {
18
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.searchResults.${key}`)
19
+
16
20
  return (
17
21
  <main id="board-content" tabIndex={-1} className={`${PAGE} flex-1 py-4 sm:py-6`}>
18
22
  <div className={`${FEED} flex flex-col gap-4`}>
19
23
  <header className="rounded-lg border border-border bg-card px-4 py-3.5 shadow-elevation">
20
24
  <h1 className="text-xl leading-tight font-bold tracking-tight text-balance">
21
- Results for &ldquo;{terms}&rdquo;
25
+ {c('resultsFor')} {terms}
22
26
  </h1>
23
27
  <p className="mt-1 text-sm text-muted-foreground">
24
- Searched <Stamp at={searchedAt} />. Results are checked against your access
25
- every time this page is opened, so they can change.
28
+ {c('searched')} <Stamp at={searchedAt} />. {c('searchedNote')}
26
29
  </p>
27
30
  </header>
28
31
 
29
- {refine !== undefined && <Refine {...refine} />}
32
+ {refine !== undefined && <Refine {...refine} copy={copy} />}
30
33
 
31
34
  {hits.length === 0 ? (
32
35
  <p className="rounded-lg border border-border bg-card px-4 py-6 text-center text-sm text-muted-foreground shadow-elevation">
33
- Nothing matched — or nothing you can see does. Try fewer words, or a different
34
- spelling.
36
+ {c('noHits')}
35
37
  </p>
36
38
  ) : (
37
39
  <ul className="flex flex-col gap-3">
@@ -63,11 +65,7 @@ export function SearchResults({
63
65
  ))}
64
66
 
65
67
  <section className="rounded-lg border border-border bg-card shadow-elevation">
66
- <form
67
- method="get"
68
- action={within.action}
69
- className="flex flex-col gap-3 px-4 py-4"
70
- >
68
+ <form method="get" action={within.action} className="flex flex-col gap-3 px-4 py-4">
71
69
  {(within.hidden ?? []).map((field) => (
72
70
  <input
73
71
  key={`${field.name}-${field.value}`}
@@ -107,7 +105,7 @@ export function SearchResults({
107
105
  </section>
108
106
 
109
107
  <a href={newSearchHref} className={`text-sm font-semibold ${LINK}`}>
110
- Start a new search
108
+ {c('newSearch')}
111
109
  </a>
112
110
  </div>
113
111
  </main>
@@ -125,7 +123,10 @@ function Refine({
125
123
  submitLabel,
126
124
  applied,
127
125
  clearHref,
128
- }: SearchRefineModel) {
126
+ copy,
127
+ }: SearchRefineModel & { copy: SlotCopy }) {
128
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.searchResults.${key}`)
129
+
129
130
  return (
130
131
  <section
131
132
  aria-label={label}
@@ -162,7 +163,7 @@ function Refine({
162
163
  >
163
164
  {chip.label}
164
165
  <span aria-hidden="true">×</span>
165
- <span className="sr-only">— remove this filter</span>
166
+ <span className="sr-only">{c('removeFilter')}</span>
166
167
  </a>
167
168
  ))}
168
169
  </div>
@@ -182,7 +183,7 @@ function Refine({
182
183
 
183
184
  {clearHref !== null && (
184
185
  <a href={clearHref} className={`text-xs font-semibold ${LINK}`}>
185
- Clear filters
186
+ {c('clearFilters')}
186
187
  </a>
187
188
  )}
188
189
  </form>
@@ -1,6 +1,7 @@
1
- import type { ShellModel } from '@meith/theme-kit'
1
+ import type { ShellModel, SlotCopy } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
2
3
 
3
- export function Shell({ viewer, children }: ShellModel) {
4
+ export function Shell({ viewer, children, copy }: ShellModel & { copy: SlotCopy }) {
4
5
  return (
5
6
  <div
6
7
  className="flex min-h-dvh flex-col bg-background text-foreground"
@@ -10,7 +11,7 @@ export function Shell({ viewer, children }: ShellModel) {
10
11
  href="#board-content"
11
12
  className="sr-only focus-visible:not-sr-only focus-visible:absolute focus-visible:top-3 focus-visible:left-3 focus-visible:z-50 focus-visible:inline-flex focus-visible:h-9 focus-visible:items-center focus-visible:rounded-full focus-visible:bg-primary focus-visible:px-4 focus-visible:text-sm focus-visible:font-semibold focus-visible:text-primary-foreground focus-visible:shadow-elevation"
12
13
  >
13
- Skip to content
14
+ {fromSlotCopy(copy, 'phasebook.shell.skipToContent')}
14
15
  </a>
15
16
  {children}
16
17
  </div>
@@ -1,10 +1,13 @@
1
- import type { SubforumListModel } from '@meith/theme-kit'
1
+ import type { SlotCopy, SubforumListModel } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
2
3
 
3
- import { NUMERIC, count, plural } from '../shared'
4
+ import { count, NUMERIC, plural } from '../shared'
4
5
 
5
- export function SubforumList({ forums }: SubforumListModel) {
6
+ export function SubforumList({ forums, copy }: SubforumListModel & { copy: SlotCopy }) {
6
7
  if (forums.length === 0) return null
7
8
 
9
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.subforumList.${key}`)
10
+
8
11
  return (
9
12
  <section
10
13
  aria-labelledby="subforums-heading"
@@ -14,7 +17,7 @@ export function SubforumList({ forums }: SubforumListModel) {
14
17
  id="subforums-heading"
15
18
  className="px-4 pt-3 pb-1 text-[0.9375rem] font-semibold text-muted-foreground"
16
19
  >
17
- Subforums
20
+ {c('title')}
18
21
  </h2>
19
22
 
20
23
  <ul className="flex flex-wrap gap-2 px-4 pt-1 pb-3">
@@ -27,7 +30,8 @@ export function SubforumList({ forums }: SubforumListModel) {
27
30
  {forum.title}
28
31
  {forum.type !== 'link' && (
29
32
  <span className={`text-xs font-medium text-muted-foreground ${NUMERIC}`}>
30
- {count(forum.threadCount)} {plural(forum.threadCount, 'thread', 'threads')}
33
+ {count(forum.threadCount)}{' '}
34
+ {plural(forum.threadCount, c('thread.one'), c('thread.other'))}
31
35
  </span>
32
36
  )}
33
37
  </a>
@@ -1,8 +1,11 @@
1
- import type { ThreadRowSlotModel } from '@meith/theme-kit'
1
+ import type { SlotCopy, ThreadRowSlotModel } from '@meith/theme-kit'
2
+ import { fromSlotCopy } from '@meith/theme-kit'
2
3
 
3
- import { Circle, MUTED_LINK, NUMERIC, Prefix, Stamp, Tag, UserRef, count, plural } from '../shared'
4
+ import { Circle, count, MUTED_LINK, NUMERIC, Prefix, plural, Stamp, Tag, UserRef } from '../shared'
5
+
6
+ export function ThreadRow({ thread, select, copy }: ThreadRowSlotModel & { copy: SlotCopy }) {
7
+ const c = (key: string) => fromSlotCopy(copy, `phasebook.threadRow.${key}`)
4
8
 
5
- export function ThreadRow({ thread, select }: ThreadRowSlotModel) {
6
9
  return (
7
10
  <li
8
11
  data-unread={thread.isUnread ? '' : undefined}
@@ -35,9 +38,9 @@ export function ThreadRow({ thread, select }: ThreadRowSlotModel) {
35
38
  <div className="min-w-0 flex-1">
36
39
  <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
37
40
  {thread.prefix !== null && <Prefix prefix={thread.prefix} />}
38
- {thread.isSticky && <Tag token="thread-pinned">Pinned</Tag>}
39
- {thread.isLocked && <Tag token="thread-locked">Locked</Tag>}
40
- {thread.isMoved && <Tag token="thread-moved">Moved</Tag>}
41
+ {thread.isSticky && <Tag token="thread-pinned">{c('pinned')}</Tag>}
42
+ {thread.isLocked && <Tag token="thread-locked">{c('locked')}</Tag>}
43
+ {thread.isMoved && <Tag token="thread-moved">{c('moved')}</Tag>}
41
44
  </div>
42
45
 
43
46
  <a
@@ -46,29 +49,29 @@ export function ThreadRow({ thread, select }: ThreadRowSlotModel) {
46
49
  >
47
50
  {thread.title}
48
51
  </a>
49
- {thread.isUnread && <span className="sr-only"> (new posts)</span>}
52
+ {thread.isUnread && <span className="sr-only"> {c('newPosts')}</span>}
50
53
 
51
54
  <p className="mt-0.5 text-xs text-muted-foreground">
52
- Started by <UserRef user={thread.author} className="text-xs font-medium" />
55
+ {c('startedBy')} <UserRef user={thread.author} className="text-xs font-medium" />
53
56
  </p>
54
57
  </div>
55
58
  </div>
56
59
 
57
60
  <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-xs text-muted-foreground">
58
61
  <span className={NUMERIC}>
59
- {count(thread.replyCount)} {plural(thread.replyCount, 'reply', 'replies')} ·{' '}
60
- {count(thread.viewCount)} {plural(thread.viewCount, 'view', 'views')}
62
+ {count(thread.replyCount)} {plural(thread.replyCount, c('reply.one'), c('reply.other'))} ·{' '}
63
+ {count(thread.viewCount)} {plural(thread.viewCount, c('view.one'), c('view.other'))}
61
64
  </span>
62
65
 
63
66
  <span className="min-w-0 truncate">
64
67
  {thread.lastPost === null ? (
65
- <span className="text-thread-moved">No replies yet</span>
68
+ <span className="text-thread-moved">{c('noRepliesYet')}</span>
66
69
  ) : (
67
70
  <>
68
71
  <a href={thread.lastPost.href} className={`font-semibold ${MUTED_LINK}`}>
69
- Latest reply
72
+ {c('latestReply')}
70
73
  </a>
71
- {' by '}
74
+ {` ${c('by')} `}
72
75
  <UserRef user={thread.lastPost.author} className="text-xs font-medium" />
73
76
  {' · '}
74
77
  <Stamp at={thread.lastPost.at} />