@meith/theme-default 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 +32 -0
- package/src/index.ts +10 -0
- package/src/shared.tsx +102 -0
- package/src/slots/announcement.tsx +42 -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 +33 -0
- package/src/slots/footer.tsx +34 -0
- package/src/slots/forum-display.tsx +103 -0
- package/src/slots/forum-jump.tsx +46 -0
- package/src/slots/forum-row.tsx +90 -0
- package/src/slots/header.tsx +60 -0
- package/src/slots/latest-posts.tsx +53 -0
- package/src/slots/latest-threads.tsx +57 -0
- package/src/slots/member-profile.tsx +117 -0
- package/src/slots/navigation.tsx +40 -0
- package/src/slots/notice.tsx +27 -0
- package/src/slots/pagination.tsx +75 -0
- package/src/slots/post-actions.tsx +54 -0
- package/src/slots/post-bit.tsx +245 -0
- package/src/slots/post-form.tsx +36 -0
- package/src/slots/redirect-notice.tsx +25 -0
- package/src/slots/search-form.tsx +94 -0
- package/src/slots/shell.tsx +18 -0
- package/src/slots/subforum-list.tsx +48 -0
- package/src/slots/thread-row.tsx +90 -0
- package/src/slots/thread-view.tsx +77 -0
- package/src/slots/user-panel.tsx +102 -0
- package/src/slots/who-is-online.tsx +94 -0
- package/src/theme.ts +70 -0
- package/src/tokens.ts +156 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { Alert, AlertDescription, AlertTitle, Avatar, Card } from '@meith/ui'
|
|
2
|
+
import type { PostBitSlotModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { LINK, MUTED_LINK, NUMERIC, Stamp, UserRef } from '../shared'
|
|
5
|
+
|
|
6
|
+
const VISIBILITY_TINT = {
|
|
7
|
+
visible: '',
|
|
8
|
+
unapproved: 'border-thread-unapproved/50 bg-post-unapproved/40',
|
|
9
|
+
deleted: 'border-thread-deleted/50 bg-destructive/5',
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
function StatusBanner({ visibility }: { visibility: PostBitSlotModel['post']['visibility'] }) {
|
|
13
|
+
if (visibility === 'visible') return null
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Alert
|
|
17
|
+
tone={visibility === 'deleted' ? 'error' : 'warning'}
|
|
18
|
+
className="rounded-none border-t-0 border-r-0"
|
|
19
|
+
>
|
|
20
|
+
<AlertDescription>
|
|
21
|
+
<AlertTitle>
|
|
22
|
+
{visibility === 'deleted' ? 'Deleted post.' : 'Waiting for approval.'}
|
|
23
|
+
</AlertTitle>{' '}
|
|
24
|
+
Only moderators can see this.
|
|
25
|
+
</AlertDescription>
|
|
26
|
+
</Alert>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function GroupBadge({ badge }: { badge: NonNullable<PostBitSlotModel['post']['author']['badge']> }) {
|
|
31
|
+
const image = (
|
|
32
|
+
<img
|
|
33
|
+
src={badge.src}
|
|
34
|
+
alt=""
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
className="h-4 w-auto max-w-full object-contain"
|
|
37
|
+
loading="lazy"
|
|
38
|
+
decoding="async"
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if (badge.darkSrc === null) return image
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<picture>
|
|
46
|
+
<source media="(prefers-color-scheme: dark)" srcSet={badge.darkSrc} />
|
|
47
|
+
{image}
|
|
48
|
+
</picture>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function AuthorBlock({
|
|
53
|
+
author,
|
|
54
|
+
badges,
|
|
55
|
+
}: {
|
|
56
|
+
author: PostBitSlotModel['post']['author']
|
|
57
|
+
badges: React.ReactNode
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex gap-3 sm:flex-col sm:gap-2 sm:text-center">
|
|
61
|
+
<Avatar src={author.avatarUrl} name={author.username} size={48} className="sm:self-center" />
|
|
62
|
+
|
|
63
|
+
<div className="min-w-0 flex-1 sm:flex-none">
|
|
64
|
+
<p className="truncate text-sm">
|
|
65
|
+
<UserRef user={author} className="font-semibold text-foreground" />
|
|
66
|
+
</p>
|
|
67
|
+
|
|
68
|
+
{author.badge != null && (
|
|
69
|
+
<p className="mt-1 flex sm:justify-center">
|
|
70
|
+
<GroupBadge badge={author.badge} />
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{author.title !== null && (
|
|
75
|
+
<p className="truncate text-xs text-muted-foreground">{author.title}</p>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{author.isOnline && (
|
|
79
|
+
<p className="mt-1 flex items-center gap-1.5 text-xs text-moderation-approved sm:justify-center">
|
|
80
|
+
<span aria-hidden="true" className="size-1.5 rounded-full bg-moderation-approved" />
|
|
81
|
+
Online
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{badges}
|
|
86
|
+
|
|
87
|
+
<dl className={`mt-1.5 text-xs text-muted-foreground ${NUMERIC}`}>
|
|
88
|
+
<div className="flex gap-1 sm:justify-center">
|
|
89
|
+
<dt className="sr-only">Posts</dt>
|
|
90
|
+
<dd>
|
|
91
|
+
{author.postCount.toLocaleString('en')} {author.postCount === 1 ? 'post' : 'posts'}
|
|
92
|
+
</dd>
|
|
93
|
+
</div>
|
|
94
|
+
{author.joinedAt !== null && (
|
|
95
|
+
<div className="flex gap-1 sm:justify-center">
|
|
96
|
+
<dt className="sr-only">Joined</dt>
|
|
97
|
+
<dd>
|
|
98
|
+
Joined <Stamp at={author.joinedAt} />
|
|
99
|
+
</dd>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
{author.reputation != null && (
|
|
103
|
+
<div className="flex gap-1 sm:justify-center">
|
|
104
|
+
<dt className="sr-only">Reputation</dt>
|
|
105
|
+
<dd>{author.reputation.toLocaleString('en')} reputation</dd>
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</dl>
|
|
109
|
+
|
|
110
|
+
{author.fields.length > 0 && (
|
|
111
|
+
<dl className="mt-1.5 space-y-0.5 text-xs text-muted-foreground">
|
|
112
|
+
{author.fields.map((field) => (
|
|
113
|
+
<div key={field.label} className="flex justify-center gap-1">
|
|
114
|
+
<dt className="font-medium">{field.label}:</dt>
|
|
115
|
+
<dd className="truncate">{field.value}</dd>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</dl>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function PostBit({ post, select, regions }: PostBitSlotModel) {
|
|
126
|
+
return (
|
|
127
|
+
<Card
|
|
128
|
+
as="article"
|
|
129
|
+
id={`post-${post.number}`}
|
|
130
|
+
data-post-id={post.id}
|
|
131
|
+
data-visibility={post.visibility}
|
|
132
|
+
className={VISIBILITY_TINT[post.visibility]}
|
|
133
|
+
>
|
|
134
|
+
<StatusBanner visibility={post.visibility} />
|
|
135
|
+
|
|
136
|
+
<div className="grid grid-cols-1 sm:grid-cols-[11rem_minmax(0,1fr)]">
|
|
137
|
+
<div className="border-b border-border px-4 py-3 sm:border-r sm:border-b-0 sm:bg-muted/40">
|
|
138
|
+
<AuthorBlock author={post.author} badges={regions.pluginBadges} />
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className="min-w-0">
|
|
142
|
+
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2 text-xs text-muted-foreground">
|
|
143
|
+
{select === null ? (
|
|
144
|
+
<span />
|
|
145
|
+
) : (
|
|
146
|
+
<label className="flex items-center gap-2">
|
|
147
|
+
<input
|
|
148
|
+
type="checkbox"
|
|
149
|
+
name={select.name}
|
|
150
|
+
value={select.value}
|
|
151
|
+
form={select.formId}
|
|
152
|
+
className="size-4 accent-primary"
|
|
153
|
+
/>
|
|
154
|
+
<span className="sr-only">{select.label}</span>
|
|
155
|
+
</label>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
<a href={post.permalink} className={MUTED_LINK}>
|
|
159
|
+
<Stamp at={post.postedAt} />
|
|
160
|
+
<span className={`ml-2 ${NUMERIC}`}>#{post.number}</span>
|
|
161
|
+
</a>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{post.ignored !== null ? (
|
|
165
|
+
<div className="px-4 py-5 text-sm text-muted-foreground">
|
|
166
|
+
You are ignoring{' '}
|
|
167
|
+
<span className="font-medium text-foreground">{post.ignored.authorUsername}</span>.
|
|
168
|
+
This post is hidden.{' '}
|
|
169
|
+
<a href={post.ignored.revealHref} className={`font-medium text-foreground ${LINK}`}>
|
|
170
|
+
Show it anyway
|
|
171
|
+
</a>
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
<div className="px-4 py-4">
|
|
175
|
+
<div
|
|
176
|
+
className="prose-md text-sm"
|
|
177
|
+
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
{post.editedNote !== null && (
|
|
181
|
+
<p className="mt-4 border-t border-border pt-2 text-xs text-muted-foreground">
|
|
182
|
+
{post.editedNote}
|
|
183
|
+
</p>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{post.attachments.length > 0 && (
|
|
189
|
+
<div className="border-t border-border px-4 py-3">
|
|
190
|
+
<h4 className="mb-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
|
191
|
+
{post.attachments.length}{' '}
|
|
192
|
+
{post.attachments.length === 1 ? 'attachment' : 'attachments'}
|
|
193
|
+
</h4>
|
|
194
|
+
<ul className="flex flex-wrap gap-3">
|
|
195
|
+
{post.attachments.map((file) => (
|
|
196
|
+
<li key={file.id} className="max-w-full">
|
|
197
|
+
<a
|
|
198
|
+
href={file.href}
|
|
199
|
+
className="group block max-w-full text-xs text-muted-foreground hover:text-foreground"
|
|
200
|
+
>
|
|
201
|
+
{file.isImage ? (
|
|
202
|
+
<img
|
|
203
|
+
src={file.thumbnailHref ?? file.href}
|
|
204
|
+
alt={file.filename}
|
|
205
|
+
width={file.width ?? undefined}
|
|
206
|
+
height={file.height ?? undefined}
|
|
207
|
+
loading="lazy"
|
|
208
|
+
decoding="async"
|
|
209
|
+
className="mb-1 max-h-56 w-auto rounded-md border border-border object-contain"
|
|
210
|
+
/>
|
|
211
|
+
) : null}
|
|
212
|
+
<span className="block truncate">
|
|
213
|
+
<span className="font-medium group-hover:underline">{file.filename}</span>{' '}
|
|
214
|
+
<span className="opacity-70">({file.size})</span>
|
|
215
|
+
</span>
|
|
216
|
+
</a>
|
|
217
|
+
</li>
|
|
218
|
+
))}
|
|
219
|
+
</ul>
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{post.ignored === null && post.author.signatureHtml !== null && (
|
|
224
|
+
<div
|
|
225
|
+
className="prose-md border-t border-border px-4 py-2.5 text-xs text-muted-foreground"
|
|
226
|
+
dangerouslySetInnerHTML={{ __html: post.author.signatureHtml }}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{regions.pluginFooter !== undefined && regions.pluginFooter !== null && (
|
|
231
|
+
<div className="border-t border-border px-4 py-2 text-xs empty:hidden">
|
|
232
|
+
{regions.pluginFooter}
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{regions.actions !== null && (
|
|
237
|
+
<footer className="border-t border-border px-4 py-2 empty:hidden">
|
|
238
|
+
{regions.actions}
|
|
239
|
+
</footer>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</Card>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Alert, AlertDescription, AlertTitle, Card } from '@meith/ui'
|
|
2
|
+
import type { PostFormModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { MUTED_LINK, pageAt } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function PostForm({
|
|
7
|
+
heading,
|
|
8
|
+
cancelHref,
|
|
9
|
+
cancelLabel,
|
|
10
|
+
errorMessage,
|
|
11
|
+
regions,
|
|
12
|
+
}: PostFormModel) {
|
|
13
|
+
return (
|
|
14
|
+
<div className={`${pageAt('max-w-3xl')} flex w-full flex-col gap-5 py-6 sm:py-8`}>
|
|
15
|
+
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
|
|
16
|
+
<h1 className="text-2xl font-semibold tracking-tight text-balance">{heading}</h1>
|
|
17
|
+
<a href={cancelHref} className={`text-sm ${MUTED_LINK}`}>
|
|
18
|
+
{cancelLabel}
|
|
19
|
+
</a>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
{errorMessage !== null && (
|
|
23
|
+
<Alert tone="error">
|
|
24
|
+
<AlertDescription>
|
|
25
|
+
<AlertTitle>Cannot post.</AlertTitle> {errorMessage}
|
|
26
|
+
</AlertDescription>
|
|
27
|
+
</Alert>
|
|
28
|
+
)}
|
|
29
|
+
|
|
30
|
+
<Card>
|
|
31
|
+
{regions.toolbar}
|
|
32
|
+
<div className="p-4 sm:p-5">{regions.form}</div>
|
|
33
|
+
</Card>
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Card, CardContent, buttonVariants } from '@meith/ui'
|
|
2
|
+
import type { RedirectNoticeModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
export function RedirectNotice({ message, targetHref, delaySeconds }: RedirectNoticeModel) {
|
|
5
|
+
return (
|
|
6
|
+
<Card className="w-full max-w-lg">
|
|
7
|
+
<CardContent className="p-6">
|
|
8
|
+
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
9
|
+
Redirecting
|
|
10
|
+
</p>
|
|
11
|
+
<h1 className="mt-1 text-2xl font-semibold tracking-tight">Please wait</h1>
|
|
12
|
+
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
|
|
13
|
+
|
|
14
|
+
<div className="mt-5 flex flex-wrap items-center gap-3">
|
|
15
|
+
<a href={targetHref} className={buttonVariants({ variant: 'primary' })}>
|
|
16
|
+
Continue now
|
|
17
|
+
</a>
|
|
18
|
+
<span className="text-xs text-muted-foreground">
|
|
19
|
+
Continuing on its own in {delaySeconds} {delaySeconds === 1 ? 'second' : 'seconds'}.
|
|
20
|
+
</span>
|
|
21
|
+
</div>
|
|
22
|
+
</CardContent>
|
|
23
|
+
</Card>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Alert,
|
|
3
|
+
AlertDescription,
|
|
4
|
+
AlertTitle,
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
Field,
|
|
8
|
+
Input,
|
|
9
|
+
NativeSelect,
|
|
10
|
+
buttonVariants,
|
|
11
|
+
} from '@meith/ui'
|
|
12
|
+
import type { OptionModel, SearchFormModel } from '@meith/theme-kit'
|
|
13
|
+
|
|
14
|
+
export function SearchForm({
|
|
15
|
+
action,
|
|
16
|
+
fields,
|
|
17
|
+
query,
|
|
18
|
+
maxQueryLength,
|
|
19
|
+
forums,
|
|
20
|
+
sorts,
|
|
21
|
+
hint,
|
|
22
|
+
errorMessage,
|
|
23
|
+
}: SearchFormModel) {
|
|
24
|
+
return (
|
|
25
|
+
<Card>
|
|
26
|
+
<CardContent className="p-4 sm:p-5">
|
|
27
|
+
<form method="get" action={action} className="flex flex-col gap-4">
|
|
28
|
+
<Field
|
|
29
|
+
name={fields.query}
|
|
30
|
+
label="Search for"
|
|
31
|
+
error={errorMessage}
|
|
32
|
+
{...(hint === null ? {} : { description: hint })}
|
|
33
|
+
>
|
|
34
|
+
{(control) => (
|
|
35
|
+
<Input
|
|
36
|
+
{...control}
|
|
37
|
+
defaultValue={query}
|
|
38
|
+
maxLength={maxQueryLength}
|
|
39
|
+
type="search"
|
|
40
|
+
autoComplete="off"
|
|
41
|
+
placeholder="Words in a subject or a post"
|
|
42
|
+
/>
|
|
43
|
+
)}
|
|
44
|
+
</Field>
|
|
45
|
+
|
|
46
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
47
|
+
<Choice label="In" name={fields.forum} options={forums} />
|
|
48
|
+
<Choice label="Sort by" name={fields.sort} options={sorts} />
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div>
|
|
52
|
+
<button type="submit" className={buttonVariants({ variant: 'primary' })}>
|
|
53
|
+
Search
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</form>
|
|
57
|
+
|
|
58
|
+
{errorMessage !== null && (
|
|
59
|
+
<Alert tone="error" className="mt-4">
|
|
60
|
+
<AlertDescription>
|
|
61
|
+
<AlertTitle>No results.</AlertTitle> {errorMessage}
|
|
62
|
+
</AlertDescription>
|
|
63
|
+
</Alert>
|
|
64
|
+
)}
|
|
65
|
+
</CardContent>
|
|
66
|
+
</Card>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function Choice({
|
|
71
|
+
label,
|
|
72
|
+
name,
|
|
73
|
+
options,
|
|
74
|
+
}: {
|
|
75
|
+
label: string
|
|
76
|
+
name: string
|
|
77
|
+
options: readonly OptionModel[]
|
|
78
|
+
}) {
|
|
79
|
+
const selected = options.find((option) => option.isSelected)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Field name={name} label={label}>
|
|
83
|
+
{(control) => (
|
|
84
|
+
<NativeSelect {...control} defaultValue={selected?.value ?? ''}>
|
|
85
|
+
{options.map((option) => (
|
|
86
|
+
<option key={option.value} value={option.value}>
|
|
87
|
+
{option.label}
|
|
88
|
+
</option>
|
|
89
|
+
))}
|
|
90
|
+
</NativeSelect>
|
|
91
|
+
)}
|
|
92
|
+
</Field>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -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:left-4 focus-visible:top-4 focus-visible:z-50 focus-visible:inline-flex focus-visible:h-9 focus-visible:items-center focus-visible:rounded-md focus-visible:border focus-visible:border-border focus-visible:bg-card focus-visible:px-3 focus-visible:text-sm focus-visible:font-medium focus-visible:text-foreground focus-visible:shadow-lg"
|
|
12
|
+
>
|
|
13
|
+
Skip to content
|
|
14
|
+
</a>
|
|
15
|
+
{children}
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@meith/ui'
|
|
2
|
+
import type { SubforumListModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { Counts, LINK } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function SubforumList({ forums }: SubforumListModel) {
|
|
7
|
+
if (forums.length === 0) return null
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Card aria-labelledby="subforums-heading">
|
|
11
|
+
<CardHeader>
|
|
12
|
+
<CardTitle id="subforums-heading" className="text-sm">
|
|
13
|
+
Subforums
|
|
14
|
+
</CardTitle>
|
|
15
|
+
</CardHeader>
|
|
16
|
+
|
|
17
|
+
<CardContent>
|
|
18
|
+
<ul className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
|
19
|
+
{forums.map((forum) => (
|
|
20
|
+
<li key={forum.id} className="min-w-0">
|
|
21
|
+
<a href={forum.href} className={`font-medium text-foreground ${LINK}`}>
|
|
22
|
+
{forum.title}
|
|
23
|
+
</a>
|
|
24
|
+
{forum.type !== 'link' && (
|
|
25
|
+
<Counts
|
|
26
|
+
items={[
|
|
27
|
+
{
|
|
28
|
+
label: 'Threads',
|
|
29
|
+
value: forum.threadCount,
|
|
30
|
+
one: 'thread',
|
|
31
|
+
many: 'threads',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: 'Posts',
|
|
35
|
+
value: forum.postCount,
|
|
36
|
+
one: 'post',
|
|
37
|
+
many: 'posts',
|
|
38
|
+
},
|
|
39
|
+
]}
|
|
40
|
+
/>
|
|
41
|
+
)}
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
</CardContent>
|
|
46
|
+
</Card>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Badge } from '@meith/ui'
|
|
2
|
+
import type { ThreadRowSlotModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { Counts, LINK, Prefix, ReadSpacer, Stamp, UnreadDot, UserRef } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function ThreadRow({ thread, select }: ThreadRowSlotModel) {
|
|
7
|
+
return (
|
|
8
|
+
<li
|
|
9
|
+
data-unread={thread.isUnread ? '' : undefined}
|
|
10
|
+
className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-2 px-4 py-3 transition-colors hover:bg-muted/60 md:grid-cols-[auto_minmax(0,1fr)_9rem_14rem] md:items-center md:gap-x-4"
|
|
11
|
+
>
|
|
12
|
+
<span className="flex items-start gap-2">
|
|
13
|
+
{select !== null && (
|
|
14
|
+
<label className="mt-0.5 flex shrink-0 items-start">
|
|
15
|
+
<span className="sr-only">{select.label}</span>
|
|
16
|
+
<input
|
|
17
|
+
type="checkbox"
|
|
18
|
+
name={select.name}
|
|
19
|
+
value={select.value}
|
|
20
|
+
form={select.formId}
|
|
21
|
+
className="size-4 accent-primary"
|
|
22
|
+
/>
|
|
23
|
+
</label>
|
|
24
|
+
)}
|
|
25
|
+
|
|
26
|
+
{thread.isUnread ? <UnreadDot /> : <ReadSpacer />}
|
|
27
|
+
</span>
|
|
28
|
+
|
|
29
|
+
<div className="min-w-0">
|
|
30
|
+
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
31
|
+
{thread.prefix !== null && <Prefix prefix={thread.prefix} />}
|
|
32
|
+
{thread.isSticky && <Badge tone="pinned">Pinned</Badge>}
|
|
33
|
+
{thread.isLocked && <Badge tone="locked">Locked</Badge>}
|
|
34
|
+
{thread.isMoved && <Badge tone="moved">Moved</Badge>}
|
|
35
|
+
|
|
36
|
+
<a
|
|
37
|
+
href={thread.href}
|
|
38
|
+
className={
|
|
39
|
+
(thread.isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground') +
|
|
40
|
+
` ${LINK}`
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
{thread.title}
|
|
44
|
+
</a>
|
|
45
|
+
{thread.isUnread && <span className="sr-only"> (new posts)</span>}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
49
|
+
Started by <UserRef user={thread.author} className="font-normal" />
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<Counts
|
|
54
|
+
className="col-start-2 md:col-start-3 md:justify-end"
|
|
55
|
+
items={[
|
|
56
|
+
{
|
|
57
|
+
label: 'Replies',
|
|
58
|
+
value: thread.replyCount,
|
|
59
|
+
one: 'reply',
|
|
60
|
+
many: 'replies',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
label: 'Views',
|
|
64
|
+
value: thread.viewCount,
|
|
65
|
+
one: 'view',
|
|
66
|
+
many: 'views',
|
|
67
|
+
},
|
|
68
|
+
]}
|
|
69
|
+
/>
|
|
70
|
+
|
|
71
|
+
<div className="col-start-2 min-w-0 text-xs text-muted-foreground md:col-start-4">
|
|
72
|
+
{thread.lastPost === null ? (
|
|
73
|
+
<span className="text-thread-moved">No replies yet</span>
|
|
74
|
+
) : (
|
|
75
|
+
<>
|
|
76
|
+
<a href={thread.lastPost.href} className={`block font-medium text-foreground ${LINK}`}>
|
|
77
|
+
Latest reply
|
|
78
|
+
</a>
|
|
79
|
+
<span className="mt-0.5 block truncate">
|
|
80
|
+
{'by '}
|
|
81
|
+
<UserRef user={thread.lastPost.author} className="font-normal" />
|
|
82
|
+
{' · '}
|
|
83
|
+
<Stamp at={thread.lastPost.at} />
|
|
84
|
+
</span>
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</li>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Badge, buttonVariants } from '@meith/ui'
|
|
2
|
+
import type { ThreadViewModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { Counts, MUTED_LINK, PAGE_BODY, Prefix } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function ThreadView({ thread, forum, replyHref, markReadAction, regions }: ThreadViewModel) {
|
|
7
|
+
return (
|
|
8
|
+
<div className={PAGE_BODY}>
|
|
9
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
|
10
|
+
<div className="min-w-0">
|
|
11
|
+
<a href={forum.href} className={`text-sm sm:hidden ${MUTED_LINK}`}>
|
|
12
|
+
{forum.label}
|
|
13
|
+
</a>
|
|
14
|
+
|
|
15
|
+
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-balance sm:mt-0">
|
|
16
|
+
{thread.title}
|
|
17
|
+
</h1>
|
|
18
|
+
|
|
19
|
+
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
20
|
+
{thread.prefix !== null && <Prefix prefix={thread.prefix} />}
|
|
21
|
+
{thread.isSticky && <Badge tone="pinned">Pinned</Badge>}
|
|
22
|
+
{thread.isLocked && <Badge tone="locked">Locked — no new replies</Badge>}
|
|
23
|
+
{thread.isMoved && <Badge tone="moved">Moved</Badge>}
|
|
24
|
+
|
|
25
|
+
<Counts
|
|
26
|
+
items={[
|
|
27
|
+
{
|
|
28
|
+
label: 'Replies',
|
|
29
|
+
value: thread.replyCount,
|
|
30
|
+
one: 'reply',
|
|
31
|
+
many: 'replies',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
label: 'Views',
|
|
35
|
+
value: thread.viewCount,
|
|
36
|
+
one: 'view',
|
|
37
|
+
many: 'views',
|
|
38
|
+
},
|
|
39
|
+
]}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
45
|
+
{markReadAction !== null && (
|
|
46
|
+
<form action={markReadAction} method="post">
|
|
47
|
+
<button type="submit" className={buttonVariants({ variant: 'ghost' })}>
|
|
48
|
+
Mark read
|
|
49
|
+
</button>
|
|
50
|
+
</form>
|
|
51
|
+
)}
|
|
52
|
+
{replyHref !== null && (
|
|
53
|
+
<a href={replyHref} className={buttonVariants({ variant: 'primary' })}>
|
|
54
|
+
Reply
|
|
55
|
+
</a>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{regions.tools !== undefined && (
|
|
61
|
+
<div className="flex flex-col gap-3 empty:hidden">{regions.tools}</div>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
<div className="flex flex-col gap-3">{regions.posts}</div>
|
|
65
|
+
|
|
66
|
+
{regions.pagination}
|
|
67
|
+
|
|
68
|
+
{regions.afterContent !== undefined && (
|
|
69
|
+
<div className="flex flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-border pt-4 empty:hidden">
|
|
70
|
+
{regions.afterContent}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{regions.quickReply}
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|