@meith/theme-phasebook 0.1.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.
@@ -0,0 +1,73 @@
1
+ import { cn } from '@meith/ui'
2
+ import type { PaginationModel } from '@meith/theme-kit'
3
+
4
+ import { NUMERIC, PILL } from '../shared'
5
+
6
+ export function Pagination({ page, pageCount, pages, previousHref, nextHref }: PaginationModel) {
7
+ if (pageCount <= 1 && previousHref === null && nextHref === null) return null
8
+
9
+ const disabled = cn(PILL, 'pointer-events-none opacity-40')
10
+
11
+ return (
12
+ <nav
13
+ aria-label="Pagination"
14
+ className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2.5 shadow-elevation"
15
+ >
16
+ {previousHref === null ? (
17
+ <span className={disabled} aria-hidden="true">
18
+ Previous
19
+ </span>
20
+ ) : (
21
+ <a href={previousHref} rel="prev" className={PILL}>
22
+ Previous
23
+ </a>
24
+ )}
25
+
26
+ <ol className="flex items-center gap-1">
27
+ {pages.map((entry, index) => {
28
+ const previous = pages[index - 1]
29
+ const gap = previous !== undefined && entry.page - previous.page > 1
30
+
31
+ return (
32
+ <li key={entry.page} className="flex items-center gap-1">
33
+ {gap && (
34
+ <span aria-hidden="true" className="px-1 text-sm text-muted-foreground">
35
+
36
+ </span>
37
+ )}
38
+ <a
39
+ href={entry.href}
40
+ aria-current={entry.isCurrent ? 'page' : undefined}
41
+ aria-label={`Page ${entry.page}`}
42
+ className={cn(
43
+ 'inline-flex size-9 items-center justify-center rounded-full text-sm font-semibold transition-colors',
44
+ NUMERIC,
45
+ entry.isCurrent
46
+ ? 'bg-primary text-primary-foreground'
47
+ : 'hidden text-muted-foreground hover:bg-accent hover:text-foreground sm:inline-flex',
48
+ )}
49
+ >
50
+ {entry.page}
51
+ </a>
52
+ </li>
53
+ )
54
+ })}
55
+
56
+ <li className={`ml-1 text-xs whitespace-nowrap text-muted-foreground ${NUMERIC}`}>
57
+ <span className="sr-only">Page </span>
58
+ {page} of {pageCount}
59
+ </li>
60
+ </ol>
61
+
62
+ {nextHref === null ? (
63
+ <span className={disabled} aria-hidden="true">
64
+ Next
65
+ </span>
66
+ ) : (
67
+ <a href={nextHref} rel="next" className={PILL}>
68
+ Next
69
+ </a>
70
+ )}
71
+ </nav>
72
+ )
73
+ }
@@ -0,0 +1,45 @@
1
+ import type { PostActionsSlotModel } from '@meith/theme-kit'
2
+
3
+ interface Action {
4
+ readonly href: string
5
+ readonly label: string
6
+ }
7
+
8
+ const ACTION =
9
+ 'inline-flex h-8 flex-1 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold ' +
10
+ 'text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
11
+
12
+ export function PostActions({ actions, children }: PostActionsSlotModel) {
13
+ 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' },
18
+ ].filter((action): action is Action => action !== null)
19
+
20
+ 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' },
24
+ ].filter((action): action is Action => action !== null)
25
+
26
+ if (reader.length === 0 && staff.length === 0 && children === undefined) return null
27
+
28
+ return (
29
+ <nav aria-label="Post actions" className="flex flex-wrap items-center gap-1">
30
+ {reader.map((action) => (
31
+ <a key={action.href} href={action.href} className={ACTION}>
32
+ {action.label}
33
+ </a>
34
+ ))}
35
+
36
+ {staff.map((action) => (
37
+ <a key={action.href} href={action.href} className={ACTION}>
38
+ {action.label}
39
+ </a>
40
+ ))}
41
+
42
+ {children !== undefined && <span className="ms-auto empty:hidden">{children}</span>}
43
+ </nav>
44
+ )
45
+ }
@@ -0,0 +1,243 @@
1
+ import { cn } from '@meith/ui'
2
+ import type { PostBitSlotModel } from '@meith/theme-kit'
3
+
4
+ import {
5
+ Circle,
6
+ LINK,
7
+ MUTED_LINK,
8
+ NUMERIC,
9
+ OnlineDot,
10
+ Stamp,
11
+ Tag,
12
+ UserRef,
13
+ count,
14
+ plural,
15
+ } from '../shared'
16
+
17
+ type Post = PostBitSlotModel['post']
18
+
19
+ const VISIBILITY_TINT = {
20
+ visible: '',
21
+ unapproved: 'border-thread-unapproved/50 bg-post-unapproved',
22
+ deleted: 'border-thread-deleted/50 bg-destructive/5',
23
+ } as const
24
+
25
+ function StatusBanner({ visibility }: { visibility: Post['visibility'] }) {
26
+ if (visibility === 'visible') return null
27
+
28
+ const deleted = visibility === 'deleted'
29
+
30
+ return (
31
+ <p
32
+ className={cn(
33
+ 'flex items-center gap-2 border-b px-4 py-2 text-xs font-semibold',
34
+ deleted
35
+ ? 'border-thread-deleted/30 text-thread-deleted'
36
+ : 'border-thread-unapproved/30 text-thread-unapproved',
37
+ )}
38
+ >
39
+ {deleted ? 'Deleted post.' : 'Waiting for approval.'}
40
+ <span className="font-normal text-muted-foreground">Only moderators can see this.</span>
41
+ </p>
42
+ )
43
+ }
44
+
45
+ function GroupBadge({ badge }: { badge: NonNullable<Post['author']['badge']> }) {
46
+ const image = (
47
+ <img
48
+ src={badge.src}
49
+ alt=""
50
+ aria-hidden="true"
51
+ className="h-4 w-auto max-w-full object-contain"
52
+ loading="lazy"
53
+ decoding="async"
54
+ />
55
+ )
56
+
57
+ if (badge.darkSrc === null) return image
58
+
59
+ return (
60
+ <picture>
61
+ <source media="(prefers-color-scheme: dark)" srcSet={badge.darkSrc} />
62
+ {image}
63
+ </picture>
64
+ )
65
+ }
66
+
67
+ function Attachments({ files }: { files: Post['attachments'] }) {
68
+ const images = files.filter((file) => file.isImage)
69
+ const rest = files.filter((file) => !file.isImage)
70
+
71
+ return (
72
+ <>
73
+ {images.length > 0 && (
74
+ <ul
75
+ className={cn(
76
+ 'grid gap-0.5 border-y border-border',
77
+ images.length === 1 ? 'grid-cols-1' : 'grid-cols-2',
78
+ )}
79
+ >
80
+ {images.map((file) => (
81
+ <li key={file.id} className="min-w-0">
82
+ <a href={file.href} className="block">
83
+ <img
84
+ src={file.thumbnailHref ?? file.href}
85
+ alt={file.filename}
86
+ width={file.width ?? undefined}
87
+ height={file.height ?? undefined}
88
+ loading="lazy"
89
+ decoding="async"
90
+ className="max-h-96 w-full bg-surface object-cover"
91
+ />
92
+ </a>
93
+ </li>
94
+ ))}
95
+ </ul>
96
+ )}
97
+
98
+ {rest.length > 0 && (
99
+ <ul className="flex flex-col gap-1 px-4 py-2.5">
100
+ {rest.map((file) => (
101
+ <li key={file.id} className="min-w-0">
102
+ <a
103
+ href={file.href}
104
+ className="flex items-center gap-2 rounded-lg bg-secondary px-3 py-2 text-xs transition-colors hover:bg-accent"
105
+ >
106
+ <span className="truncate font-semibold text-foreground">{file.filename}</span>
107
+ <span className="shrink-0 text-muted-foreground">{file.size}</span>
108
+ </a>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ )}
113
+ </>
114
+ )
115
+ }
116
+
117
+ export function PostBit({ post, select, regions }: PostBitSlotModel) {
118
+ const { author } = post
119
+
120
+ return (
121
+ <article
122
+ id={`post-${post.number}`}
123
+ data-post-id={post.id}
124
+ data-visibility={post.visibility}
125
+ className={cn(
126
+ 'overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-elevation',
127
+ VISIBILITY_TINT[post.visibility],
128
+ )}
129
+ >
130
+ <StatusBanner visibility={post.visibility} />
131
+
132
+ <header className="flex items-start gap-3.5 px-5 pt-4">
133
+ {select !== null && (
134
+ <label className="mt-3 flex shrink-0 items-start">
135
+ <span className="sr-only">{select.label}</span>
136
+ <input
137
+ type="checkbox"
138
+ name={select.name}
139
+ value={select.value}
140
+ form={select.formId}
141
+ className="size-4 accent-primary"
142
+ />
143
+ </label>
144
+ )}
145
+
146
+ <span className="relative shrink-0">
147
+ <Circle src={author.avatarUrl} name={author.username} size={48} />
148
+ {author.isOnline && <OnlineDot />}
149
+ </span>
150
+
151
+ <div className="min-w-0 flex-1">
152
+ <p className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-[0.9375rem] leading-snug">
153
+ <UserRef user={author} className="text-[0.9375rem]" />
154
+ {author.badge != null && <GroupBadge badge={author.badge} />}
155
+ {author.title !== null && <Tag>{author.title}</Tag>}
156
+ </p>
157
+
158
+ <p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
159
+ <a href={post.permalink} className={MUTED_LINK}>
160
+ <Stamp at={post.postedAt} />
161
+ </a>
162
+ <span aria-hidden="true">·</span>
163
+ <span className={NUMERIC}>#{post.number}</span>
164
+ {author.isOnline && (
165
+ <>
166
+ <span aria-hidden="true">·</span>
167
+ <span className="text-moderation-approved">Online</span>
168
+ </>
169
+ )}
170
+ </p>
171
+
172
+ {regions.pluginBadges !== undefined && (
173
+ <p className="mt-2 flex flex-wrap items-center gap-1.5 empty:hidden">
174
+ {regions.pluginBadges}
175
+ </p>
176
+ )}
177
+ </div>
178
+
179
+ <p className={`shrink-0 text-right text-xs leading-relaxed text-muted-foreground ${NUMERIC}`}>
180
+ {count(author.postCount)} {plural(author.postCount, 'post', 'posts')}
181
+ {author.reputation != null && (
182
+ <>
183
+ <br />
184
+ {count(author.reputation)} rep
185
+ </>
186
+ )}
187
+ </p>
188
+ </header>
189
+
190
+ {post.ignored !== null ? (
191
+ <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.{' '}
195
+ <a href={post.ignored.revealHref} className={LINK}>
196
+ Show it anyway
197
+ </a>
198
+ </p>
199
+ ) : (
200
+ <div className="px-5 pt-3 pb-4">
201
+ <div
202
+ className="prose-md text-[0.9375rem]"
203
+ dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
204
+ />
205
+
206
+ {post.editedNote !== null && (
207
+ <p className="mt-3 text-xs text-muted-foreground">{post.editedNote}</p>
208
+ )}
209
+ </div>
210
+ )}
211
+
212
+ {post.attachments.length > 0 && <Attachments files={post.attachments} />}
213
+
214
+ {post.ignored === null && author.signatureHtml !== null && (
215
+ <div
216
+ className="prose-md border-t border-border px-4 py-2 text-xs text-muted-foreground"
217
+ dangerouslySetInnerHTML={{ __html: author.signatureHtml }}
218
+ />
219
+ )}
220
+
221
+ {author.fields.length > 0 && post.ignored === null && (
222
+ <dl className="flex flex-wrap gap-x-4 gap-y-0.5 border-t border-border px-4 py-2 text-xs text-muted-foreground">
223
+ {author.fields.map((field) => (
224
+ <div key={field.label} className="flex gap-1">
225
+ <dt className="font-semibold">{field.label}:</dt>
226
+ <dd className="truncate">{field.value}</dd>
227
+ </div>
228
+ ))}
229
+ </dl>
230
+ )}
231
+
232
+ {regions.pluginFooter !== undefined && (
233
+ <div className="border-t border-border px-4 py-2 text-xs empty:hidden">
234
+ {regions.pluginFooter}
235
+ </div>
236
+ )}
237
+
238
+ {regions.actions !== null && (
239
+ <footer className="border-t border-border px-2 py-1 empty:hidden">{regions.actions}</footer>
240
+ )}
241
+ </article>
242
+ )
243
+ }
@@ -0,0 +1,41 @@
1
+ import type { PostFormModel } from '@meith/theme-kit'
2
+
3
+ import { FEED, PAGE, PILL } from '../shared'
4
+
5
+ export function PostForm({
6
+ heading,
7
+ cancelHref,
8
+ cancelLabel,
9
+ errorMessage,
10
+ regions,
11
+ }: PostFormModel) {
12
+ return (
13
+ <div className={`${PAGE} py-4 sm:py-6`}>
14
+ <div className={`${FEED} flex flex-col gap-4`}>
15
+ {errorMessage !== null && (
16
+ <p
17
+ role="alert"
18
+ className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm shadow-elevation"
19
+ >
20
+ <span className="font-semibold">Cannot post.</span> {errorMessage}
21
+ </p>
22
+ )}
23
+
24
+ <section className="overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
25
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3">
26
+ <h1 className="text-lg leading-tight font-bold tracking-tight text-balance">
27
+ {heading}
28
+ </h1>
29
+ <a href={cancelHref} className={PILL}>
30
+ {cancelLabel}
31
+ </a>
32
+ </div>
33
+
34
+ {regions.toolbar}
35
+
36
+ <div className="px-4 py-4">{regions.form}</div>
37
+ </section>
38
+ </div>
39
+ </div>
40
+ )
41
+ }
@@ -0,0 +1,24 @@
1
+ import type { RedirectNoticeModel } from '@meith/theme-kit'
2
+
3
+ import { NUMERIC, PILL_PRIMARY } from '../shared'
4
+
5
+ export function RedirectNotice({ message, targetHref, delaySeconds }: RedirectNoticeModel) {
6
+ return (
7
+ <section className="w-full max-w-lg rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
8
+ <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>
11
+ <p className="mt-2 text-sm text-muted-foreground">{message}</p>
12
+
13
+ <div className="mt-5 flex flex-wrap items-center gap-3">
14
+ <a href={targetHref} className={PILL_PRIMARY}>
15
+ Continue now
16
+ </a>
17
+ <span className={`text-xs text-muted-foreground ${NUMERIC}`}>
18
+ Continuing on its own in {delaySeconds} {delaySeconds === 1 ? 'second' : 'seconds'}.
19
+ </span>
20
+ </div>
21
+ </div>
22
+ </section>
23
+ )
24
+ }
@@ -0,0 +1,99 @@
1
+ import type { OptionModel, SearchFormModel } from '@meith/theme-kit'
2
+
3
+ import { PILL_PRIMARY } from '../shared'
4
+
5
+ const LABEL = 'mb-1 block text-sm font-semibold text-foreground'
6
+
7
+ const SELECT =
8
+ 'w-full rounded-lg border border-input bg-surface px-3 py-2 text-sm text-foreground transition-colors focus-visible:border-ring'
9
+
10
+ function Choice({
11
+ id,
12
+ label,
13
+ name,
14
+ options,
15
+ }: {
16
+ id: string
17
+ label: string
18
+ name: string
19
+ options: readonly OptionModel[]
20
+ }) {
21
+ const selected = options.find((option) => option.isSelected)
22
+
23
+ return (
24
+ <div>
25
+ <label htmlFor={id} className={LABEL}>
26
+ {label}
27
+ </label>
28
+ <select id={id} name={name} defaultValue={selected?.value ?? ''} className={SELECT}>
29
+ {options.map((option) => (
30
+ <option key={option.value} value={option.value}>
31
+ {option.label}
32
+ </option>
33
+ ))}
34
+ </select>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ export function SearchForm({
40
+ action,
41
+ fields,
42
+ query,
43
+ maxQueryLength,
44
+ forums,
45
+ sorts,
46
+ hint,
47
+ errorMessage,
48
+ }: SearchFormModel) {
49
+ const described = [hint === null ? null : 'search-hint', errorMessage === null ? null : 'search-error']
50
+ .filter((id): id is string => id !== null)
51
+ .join(' ')
52
+
53
+ return (
54
+ <section className="rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
55
+ <form method="get" action={action} className="flex flex-col gap-4 px-4 py-4">
56
+ <div>
57
+ <label htmlFor="search-query" className={LABEL}>
58
+ Search for
59
+ </label>
60
+ <input
61
+ id="search-query"
62
+ type="search"
63
+ name={fields.query}
64
+ defaultValue={query}
65
+ maxLength={maxQueryLength}
66
+ autoComplete="off"
67
+ placeholder="Words in a subject or a post"
68
+ aria-invalid={errorMessage === null ? undefined : true}
69
+ {...(described === '' ? {} : { 'aria-describedby': described })}
70
+ 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"
71
+ />
72
+
73
+ {hint !== null && (
74
+ <p id="search-hint" className="mt-1.5 text-xs text-muted-foreground">
75
+ {hint}
76
+ </p>
77
+ )}
78
+
79
+ {errorMessage !== null && (
80
+ <p id="search-error" className="mt-1.5 text-xs font-semibold text-destructive">
81
+ {errorMessage}
82
+ </p>
83
+ )}
84
+ </div>
85
+
86
+ <div className="grid gap-4 sm:grid-cols-2">
87
+ <Choice id="search-forum" label="In" name={fields.forum} options={forums} />
88
+ <Choice id="search-sort" label="Sort by" name={fields.sort} options={sorts} />
89
+ </div>
90
+
91
+ <div>
92
+ <button type="submit" className={PILL_PRIMARY}>
93
+ Search
94
+ </button>
95
+ </div>
96
+ </form>
97
+ </section>
98
+ )
99
+ }
@@ -0,0 +1,18 @@
1
+ import type { ShellModel } from '@meith/theme-kit'
2
+
3
+ export function Shell({ viewer, children }: ShellModel) {
4
+ return (
5
+ <div
6
+ className="flex min-h-dvh flex-col bg-background text-foreground"
7
+ data-viewer={viewer.isGuest ? 'guest' : 'member'}
8
+ >
9
+ <a
10
+ href="#board-content"
11
+ 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
+ Skip to content
14
+ </a>
15
+ {children}
16
+ </div>
17
+ )
18
+ }
@@ -0,0 +1,39 @@
1
+ import type { SubforumListModel } from '@meith/theme-kit'
2
+
3
+ import { NUMERIC, count, plural } from '../shared'
4
+
5
+ export function SubforumList({ forums }: SubforumListModel) {
6
+ if (forums.length === 0) return null
7
+
8
+ return (
9
+ <section
10
+ aria-labelledby="subforums-heading"
11
+ className="rounded-lg border border-border bg-card text-card-foreground shadow-elevation"
12
+ >
13
+ <h2
14
+ id="subforums-heading"
15
+ className="px-4 pt-3 pb-1 text-[0.9375rem] font-semibold text-muted-foreground"
16
+ >
17
+ Subforums
18
+ </h2>
19
+
20
+ <ul className="flex flex-wrap gap-2 px-4 pt-1 pb-3">
21
+ {forums.map((forum) => (
22
+ <li key={forum.id}>
23
+ <a
24
+ href={forum.href}
25
+ className="inline-flex items-baseline gap-1.5 rounded-full bg-secondary px-3 py-1.5 text-sm font-semibold text-foreground transition-colors hover:bg-accent"
26
+ >
27
+ {forum.title}
28
+ {forum.type !== 'link' && (
29
+ <span className={`text-xs font-medium text-muted-foreground ${NUMERIC}`}>
30
+ {count(forum.threadCount)} {plural(forum.threadCount, 'thread', 'threads')}
31
+ </span>
32
+ )}
33
+ </a>
34
+ </li>
35
+ ))}
36
+ </ul>
37
+ </section>
38
+ )
39
+ }
@@ -0,0 +1,81 @@
1
+ import type { ThreadRowSlotModel } from '@meith/theme-kit'
2
+
3
+ import { Circle, MUTED_LINK, NUMERIC, Prefix, Stamp, Tag, UserRef, count, plural } from '../shared'
4
+
5
+ export function ThreadRow({ thread, select }: ThreadRowSlotModel) {
6
+ return (
7
+ <li
8
+ data-unread={thread.isUnread ? '' : undefined}
9
+ className="overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-elevation transition-colors hover:border-input"
10
+ >
11
+ <div className="flex items-start gap-3 px-4 py-3">
12
+ {select !== null && (
13
+ <label className="mt-2.5 flex shrink-0 items-start">
14
+ <span className="sr-only">{select.label}</span>
15
+ <input
16
+ type="checkbox"
17
+ name={select.name}
18
+ value={select.value}
19
+ form={select.formId}
20
+ className="size-4 accent-primary"
21
+ />
22
+ </label>
23
+ )}
24
+
25
+ <span className="relative shrink-0">
26
+ <Circle name={thread.author.username} size={40} />
27
+ {thread.isUnread && (
28
+ <span
29
+ aria-hidden="true"
30
+ className="absolute -top-0.5 -right-0.5 size-3 rounded-full bg-forum-unread ring-2 ring-card"
31
+ />
32
+ )}
33
+ </span>
34
+
35
+ <div className="min-w-0 flex-1">
36
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
37
+ {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
+ </div>
42
+
43
+ <a
44
+ href={thread.href}
45
+ className={`mt-0.5 block text-[0.9375rem] leading-snug hover:underline ${thread.isUnread ? 'font-bold' : 'font-semibold'}`}
46
+ >
47
+ {thread.title}
48
+ </a>
49
+ {thread.isUnread && <span className="sr-only"> (new posts)</span>}
50
+
51
+ <p className="mt-0.5 text-xs text-muted-foreground">
52
+ Started by <UserRef user={thread.author} className="text-xs font-medium" />
53
+ </p>
54
+ </div>
55
+ </div>
56
+
57
+ <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
+ <span className={NUMERIC}>
59
+ {count(thread.replyCount)} {plural(thread.replyCount, 'reply', 'replies')} ·{' '}
60
+ {count(thread.viewCount)} {plural(thread.viewCount, 'view', 'views')}
61
+ </span>
62
+
63
+ <span className="min-w-0 truncate">
64
+ {thread.lastPost === null ? (
65
+ <span className="text-thread-moved">No replies yet</span>
66
+ ) : (
67
+ <>
68
+ <a href={thread.lastPost.href} className={`font-semibold ${MUTED_LINK}`}>
69
+ Latest reply
70
+ </a>
71
+ {' by '}
72
+ <UserRef user={thread.lastPost.author} className="text-xs font-medium" />
73
+ {' · '}
74
+ <Stamp at={thread.lastPost.at} />
75
+ </>
76
+ )}
77
+ </span>
78
+ </div>
79
+ </li>
80
+ )
81
+ }