@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.
- package/LICENSE.md +165 -0
- package/package.json +33 -0
- package/src/index.ts +3 -0
- package/src/shared.tsx +185 -0
- package/src/slots/announcement.tsx +37 -0
- package/src/slots/board-index.tsx +50 -0
- package/src/slots/board-stats.tsx +51 -0
- package/src/slots/category-block.tsx +25 -0
- package/src/slots/error-notice.tsx +29 -0
- package/src/slots/footer.tsx +31 -0
- package/src/slots/forum-display.tsx +108 -0
- package/src/slots/forum-jump.tsx +48 -0
- package/src/slots/forum-row.tsx +79 -0
- package/src/slots/header.tsx +90 -0
- package/src/slots/latest-posts.tsx +58 -0
- package/src/slots/latest-threads.tsx +55 -0
- package/src/slots/member-profile.tsx +130 -0
- package/src/slots/navigation.tsx +43 -0
- package/src/slots/notice.tsx +62 -0
- package/src/slots/pagination.tsx +73 -0
- package/src/slots/post-actions.tsx +45 -0
- package/src/slots/post-bit.tsx +243 -0
- package/src/slots/post-form.tsx +41 -0
- package/src/slots/redirect-notice.tsx +24 -0
- package/src/slots/search-form.tsx +99 -0
- package/src/slots/shell.tsx +18 -0
- package/src/slots/subforum-list.tsx +39 -0
- package/src/slots/thread-row.tsx +81 -0
- package/src/slots/thread-view.tsx +68 -0
- package/src/slots/user-panel.tsx +154 -0
- package/src/slots/who-is-online.tsx +87 -0
- package/src/theme.ts +72 -0
- package/src/tokens.ts +102 -0
|
@@ -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
|
+
}
|