@meith/theme-clubhouse 0.3.2
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 +220 -0
- package/src/slots/announcement.tsx +45 -0
- package/src/slots/board-index.tsx +53 -0
- package/src/slots/board-stats.tsx +53 -0
- package/src/slots/category-block.tsx +27 -0
- package/src/slots/footer.tsx +37 -0
- package/src/slots/forum-display.tsx +107 -0
- package/src/slots/forum-row.tsx +76 -0
- package/src/slots/header.tsx +71 -0
- package/src/slots/latest-posts.tsx +56 -0
- package/src/slots/latest-threads.tsx +57 -0
- package/src/slots/member-profile.tsx +118 -0
- package/src/slots/navigation.tsx +40 -0
- package/src/slots/notice.tsx +27 -0
- package/src/slots/pagination.tsx +74 -0
- package/src/slots/post-actions.tsx +55 -0
- package/src/slots/post-bit.tsx +251 -0
- package/src/slots/shell.tsx +19 -0
- package/src/slots/subforum-list.tsx +32 -0
- package/src/slots/thread-row.tsx +82 -0
- package/src/slots/thread-view.tsx +72 -0
- package/src/slots/user-panel.tsx +140 -0
- package/src/slots/who-is-online.tsx +90 -0
- package/src/theme.ts +59 -0
- package/src/tokens.ts +104 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { Alert, AlertDescription, AlertTitle, Avatar, Card } from '@meith/ui'
|
|
2
|
+
import type { PostBitSlotModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { HEADING, LINK, MICRO, MICRO_BARE, 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({
|
|
31
|
+
badge,
|
|
32
|
+
}: {
|
|
33
|
+
badge: NonNullable<PostBitSlotModel['post']['author']['badge']>
|
|
34
|
+
}) {
|
|
35
|
+
const image = (
|
|
36
|
+
<img
|
|
37
|
+
src={badge.src}
|
|
38
|
+
alt=""
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
className="h-4 w-auto max-w-full object-contain"
|
|
41
|
+
loading="lazy"
|
|
42
|
+
decoding="async"
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if (badge.darkSrc === null) return image
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<picture>
|
|
50
|
+
<source media="(prefers-color-scheme: dark)" srcSet={badge.darkSrc} />
|
|
51
|
+
{image}
|
|
52
|
+
</picture>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function StatLine({ label, children }: { label: string; children: React.ReactNode }) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
59
|
+
<dt className={MICRO}>{label}</dt>
|
|
60
|
+
<dd className={`${NUMERIC} text-xs font-semibold text-foreground`}>{children}</dd>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function AuthorBlock({
|
|
66
|
+
author,
|
|
67
|
+
badges,
|
|
68
|
+
}: {
|
|
69
|
+
author: PostBitSlotModel['post']['author']
|
|
70
|
+
badges: React.ReactNode
|
|
71
|
+
}) {
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<div aria-hidden="true" className="h-12 border-b-2 border-b-secondary bg-primary" />
|
|
75
|
+
|
|
76
|
+
<div className="relative z-10 -mt-7 px-4">
|
|
77
|
+
<Avatar
|
|
78
|
+
src={author.avatarUrl}
|
|
79
|
+
name={author.username}
|
|
80
|
+
size={52}
|
|
81
|
+
className="rounded-sm border-2 border-card"
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
<p className="mt-1.5 truncate">
|
|
85
|
+
<UserRef user={author} className={`${HEADING} text-sm`} />
|
|
86
|
+
</p>
|
|
87
|
+
|
|
88
|
+
{author.title !== null && <p className={`${MICRO} mt-0.5 truncate`}>{author.title}</p>}
|
|
89
|
+
|
|
90
|
+
{author.isOnline && (
|
|
91
|
+
<p className="mt-1 flex items-center gap-1.5 text-xs text-moderation-approved">
|
|
92
|
+
<span aria-hidden="true" className="size-1.5 rounded-full bg-moderation-approved" />
|
|
93
|
+
Online
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{author.badge != null && (
|
|
98
|
+
<p className="mt-2">
|
|
99
|
+
<GroupBadge badge={author.badge} />
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{badges}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<dl className="mt-2.5 space-y-1 border-t border-border px-4 py-2.5">
|
|
107
|
+
<StatLine label="Posts">{author.postCount.toLocaleString('en')}</StatLine>
|
|
108
|
+
{author.reputation != null && (
|
|
109
|
+
<StatLine label="Rep">{author.reputation.toLocaleString('en')}</StatLine>
|
|
110
|
+
)}
|
|
111
|
+
{author.joinedAt !== null && (
|
|
112
|
+
<StatLine label="Joined">
|
|
113
|
+
<Stamp at={author.joinedAt} />
|
|
114
|
+
</StatLine>
|
|
115
|
+
)}
|
|
116
|
+
{author.fields.map((field) => (
|
|
117
|
+
<StatLine key={field.label} label={field.label}>
|
|
118
|
+
<span className="truncate">{field.value}</span>
|
|
119
|
+
</StatLine>
|
|
120
|
+
))}
|
|
121
|
+
</dl>
|
|
122
|
+
</>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function PostBit({ post, select, regions }: PostBitSlotModel) {
|
|
127
|
+
return (
|
|
128
|
+
<Card
|
|
129
|
+
as="article"
|
|
130
|
+
id={`post-${post.number}`}
|
|
131
|
+
data-post-id={post.id}
|
|
132
|
+
data-visibility={post.visibility}
|
|
133
|
+
className={`target:bg-post-highlight ${VISIBILITY_TINT[post.visibility]}`}
|
|
134
|
+
>
|
|
135
|
+
<StatusBanner visibility={post.visibility} />
|
|
136
|
+
|
|
137
|
+
<div className="grid grid-cols-1 sm:grid-cols-[11rem_minmax(0,1fr)]">
|
|
138
|
+
<div className="border-b border-border bg-surface pb-1 sm:border-r sm:border-b-0">
|
|
139
|
+
<AuthorBlock author={post.author} badges={regions.pluginBadges} />
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div className="min-w-0">
|
|
143
|
+
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-1.5 text-xs text-muted-foreground">
|
|
144
|
+
<span className="flex items-center gap-2">
|
|
145
|
+
{select !== null && (
|
|
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-3.5 accent-primary"
|
|
153
|
+
/>
|
|
154
|
+
<span className="sr-only">{select.label}</span>
|
|
155
|
+
</label>
|
|
156
|
+
)}
|
|
157
|
+
|
|
158
|
+
{post.isFirstPost && (
|
|
159
|
+
<span
|
|
160
|
+
className={`${MICRO_BARE} rounded-sm bg-primary px-1.5 py-0.5 text-primary-foreground`}
|
|
161
|
+
>
|
|
162
|
+
Opening post
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
<Stamp at={post.postedAt} />
|
|
167
|
+
</span>
|
|
168
|
+
|
|
169
|
+
<a href={post.permalink} className={`${NUMERIC} ${MUTED_LINK}`}>
|
|
170
|
+
#{post.number}
|
|
171
|
+
</a>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{post.ignored !== null ? (
|
|
175
|
+
<div className="px-4 py-5 text-sm text-muted-foreground">
|
|
176
|
+
You are ignoring{' '}
|
|
177
|
+
<span className="font-semibold text-foreground">{post.ignored.authorUsername}</span>.
|
|
178
|
+
This post is hidden.{' '}
|
|
179
|
+
<a href={post.ignored.revealHref} className={`font-semibold text-foreground ${LINK}`}>
|
|
180
|
+
Show it anyway
|
|
181
|
+
</a>
|
|
182
|
+
</div>
|
|
183
|
+
) : (
|
|
184
|
+
<div className="px-4 py-3.5">
|
|
185
|
+
<div
|
|
186
|
+
className="prose-md text-sm"
|
|
187
|
+
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
|
|
188
|
+
/>
|
|
189
|
+
|
|
190
|
+
{post.editedNote !== null && (
|
|
191
|
+
<p className="mt-3 border-t border-border pt-2 text-xs text-muted-foreground">
|
|
192
|
+
{post.editedNote}
|
|
193
|
+
</p>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{post.attachments.length > 0 && (
|
|
199
|
+
<div className="border-t border-border px-4 py-3">
|
|
200
|
+
<h4 className={`${MICRO} mb-2`}>
|
|
201
|
+
{post.attachments.length}{' '}
|
|
202
|
+
{post.attachments.length === 1 ? 'attachment' : 'attachments'}
|
|
203
|
+
</h4>
|
|
204
|
+
<ul className="flex flex-wrap gap-3">
|
|
205
|
+
{post.attachments.map((file) => (
|
|
206
|
+
<li key={file.id} className="max-w-full">
|
|
207
|
+
<a
|
|
208
|
+
href={file.href}
|
|
209
|
+
className="group block max-w-full text-xs text-muted-foreground hover:text-foreground"
|
|
210
|
+
>
|
|
211
|
+
{file.isImage ? (
|
|
212
|
+
<img
|
|
213
|
+
src={file.thumbnailHref ?? file.href}
|
|
214
|
+
alt={file.filename}
|
|
215
|
+
width={file.width ?? undefined}
|
|
216
|
+
height={file.height ?? undefined}
|
|
217
|
+
loading="lazy"
|
|
218
|
+
decoding="async"
|
|
219
|
+
className="mb-1 max-h-56 w-auto rounded-sm border border-border object-contain"
|
|
220
|
+
/>
|
|
221
|
+
) : null}
|
|
222
|
+
<span className="block truncate">
|
|
223
|
+
<span className="font-semibold group-hover:underline">{file.filename}</span>{' '}
|
|
224
|
+
<span className="opacity-70">({file.size})</span>
|
|
225
|
+
</span>
|
|
226
|
+
</a>
|
|
227
|
+
</li>
|
|
228
|
+
))}
|
|
229
|
+
</ul>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{post.ignored === null && post.author.signatureHtml !== null && (
|
|
234
|
+
<div
|
|
235
|
+
className="prose-md border-t border-border px-4 py-2 text-xs text-muted-foreground"
|
|
236
|
+
dangerouslySetInnerHTML={{ __html: post.author.signatureHtml }}
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{regions.pluginFooter !== undefined && regions.pluginFooter !== null && (
|
|
241
|
+
<div className="border-t border-border px-4 py-2 text-xs empty:hidden">
|
|
242
|
+
{regions.pluginFooter}
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{regions.actions !== null && <footer>{regions.actions}</footer>}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</Card>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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-4 focus-visible:left-4 focus-visible:z-50 focus-visible:inline-flex focus-visible:h-9 focus-visible:items-center focus-visible:rounded-full focus-visible:border focus-visible:border-border focus-visible:bg-card focus-visible:px-4 focus-visible:text-sm focus-visible:font-semibold focus-visible:text-foreground focus-visible:shadow-elevation"
|
|
12
|
+
>
|
|
13
|
+
Skip to content
|
|
14
|
+
</a>
|
|
15
|
+
|
|
16
|
+
{children}
|
|
17
|
+
</div>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Card, CardContent } from '@meith/ui'
|
|
2
|
+
import type { SubforumListModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { HEADING, LINK, MICRO, NUMERIC, PanelHead } 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
|
+
<PanelHead id="subforums-heading" title="Subforums" />
|
|
12
|
+
|
|
13
|
+
<CardContent className="px-4 py-3">
|
|
14
|
+
<ul className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
|
|
15
|
+
{forums.map((forum) => (
|
|
16
|
+
<li key={forum.id} className="min-w-0">
|
|
17
|
+
<a href={forum.href} className={`${HEADING} text-sm text-foreground ${LINK}`}>
|
|
18
|
+
{forum.title}
|
|
19
|
+
</a>
|
|
20
|
+
{forum.type !== 'link' && (
|
|
21
|
+
<p className={`${MICRO} ${NUMERIC} mt-0.5`}>
|
|
22
|
+
{forum.threadCount.toLocaleString('en')} threads ·{' '}
|
|
23
|
+
{forum.postCount.toLocaleString('en')} posts
|
|
24
|
+
</p>
|
|
25
|
+
)}
|
|
26
|
+
</li>
|
|
27
|
+
))}
|
|
28
|
+
</ul>
|
|
29
|
+
</CardContent>
|
|
30
|
+
</Card>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Badge } from '@meith/ui'
|
|
2
|
+
import type { ThreadRowSlotModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { HEADING, LINK, MICRO, Prefix, ReadMark, Stamp, TABLE_ROW, Tally, UserRef } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function ThreadRow({ thread, select }: ThreadRowSlotModel) {
|
|
7
|
+
return (
|
|
8
|
+
<li
|
|
9
|
+
data-unread={thread.isUnread ? '' : undefined}
|
|
10
|
+
className={`${TABLE_ROW} transition-colors hover:bg-accent`}
|
|
11
|
+
>
|
|
12
|
+
<span className="flex items-start gap-2">
|
|
13
|
+
{select !== null && (
|
|
14
|
+
<label className="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="mt-0.5 size-3.5 accent-primary"
|
|
22
|
+
/>
|
|
23
|
+
</label>
|
|
24
|
+
)}
|
|
25
|
+
|
|
26
|
+
<ReadMark unread={thread.isUnread} />
|
|
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={`${HEADING} text-sm ${thread.isUnread ? 'text-forum-unread' : 'text-forum-read'} ${LINK}`}
|
|
39
|
+
>
|
|
40
|
+
{thread.title}
|
|
41
|
+
</a>
|
|
42
|
+
{thread.isUnread && <span className="sr-only"> (new posts)</span>}
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
46
|
+
Started by <UserRef user={thread.author} className="font-medium" />
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<span className="col-start-2 md:col-start-3 md:block md:text-right">
|
|
51
|
+
<Tally value={thread.replyCount} label="replies" />
|
|
52
|
+
<span className="ms-3 md:hidden">
|
|
53
|
+
<Tally value={thread.viewCount} label="views" />
|
|
54
|
+
</span>
|
|
55
|
+
</span>
|
|
56
|
+
|
|
57
|
+
<span className="hidden md:block md:text-right">
|
|
58
|
+
<Tally value={thread.viewCount} label="views" />
|
|
59
|
+
</span>
|
|
60
|
+
|
|
61
|
+
<div className="col-start-2 min-w-0 text-xs text-muted-foreground md:col-start-5">
|
|
62
|
+
{thread.lastPost === null ? (
|
|
63
|
+
<span className={MICRO}>No replies yet</span>
|
|
64
|
+
) : (
|
|
65
|
+
<>
|
|
66
|
+
<a
|
|
67
|
+
href={thread.lastPost.href}
|
|
68
|
+
className={`block font-semibold text-foreground ${LINK}`}
|
|
69
|
+
>
|
|
70
|
+
Latest reply
|
|
71
|
+
</a>
|
|
72
|
+
<span className="mt-0.5 block truncate">
|
|
73
|
+
<UserRef user={thread.lastPost.author} className="font-medium" />
|
|
74
|
+
{' · '}
|
|
75
|
+
<Stamp at={thread.lastPost.at} />
|
|
76
|
+
</span>
|
|
77
|
+
</>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
</li>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Badge, cn } from '@meith/ui'
|
|
2
|
+
import type { ThreadViewModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { BUTTON, HEADING, MICRO, MUTED_LINK, NUMERIC, PAGE_BODY, PageHead, Prefix } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function ThreadView({ thread, forum, replyHref, markReadAction, regions }: ThreadViewModel) {
|
|
7
|
+
return (
|
|
8
|
+
<div className={PAGE_BODY}>
|
|
9
|
+
<PageHead
|
|
10
|
+
actions={
|
|
11
|
+
<>
|
|
12
|
+
{markReadAction !== null && (
|
|
13
|
+
<form action={markReadAction} method="post">
|
|
14
|
+
<button
|
|
15
|
+
type="submit"
|
|
16
|
+
className={cn(
|
|
17
|
+
BUTTON,
|
|
18
|
+
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
19
|
+
)}
|
|
20
|
+
>
|
|
21
|
+
Mark read
|
|
22
|
+
</button>
|
|
23
|
+
</form>
|
|
24
|
+
)}
|
|
25
|
+
{replyHref !== null && (
|
|
26
|
+
<a
|
|
27
|
+
href={replyHref}
|
|
28
|
+
className={cn(BUTTON, 'bg-primary text-primary-foreground hover:bg-primary-hover')}
|
|
29
|
+
>
|
|
30
|
+
Reply
|
|
31
|
+
</a>
|
|
32
|
+
)}
|
|
33
|
+
</>
|
|
34
|
+
}
|
|
35
|
+
>
|
|
36
|
+
<a href={forum.href} className={`text-xs sm:hidden ${MUTED_LINK}`}>
|
|
37
|
+
{forum.label}
|
|
38
|
+
</a>
|
|
39
|
+
|
|
40
|
+
<h1 className={`${HEADING} text-xl text-balance`}>{thread.title}</h1>
|
|
41
|
+
|
|
42
|
+
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
43
|
+
{thread.prefix !== null && <Prefix prefix={thread.prefix} />}
|
|
44
|
+
{thread.isSticky && <Badge tone="pinned">Pinned</Badge>}
|
|
45
|
+
{thread.isLocked && <Badge tone="locked">Locked — no new replies</Badge>}
|
|
46
|
+
{thread.isMoved && <Badge tone="moved">Moved</Badge>}
|
|
47
|
+
|
|
48
|
+
<span className={`${MICRO} ${NUMERIC}`}>
|
|
49
|
+
{thread.replyCount.toLocaleString('en')} replies ·{' '}
|
|
50
|
+
{thread.viewCount.toLocaleString('en')} views
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</PageHead>
|
|
54
|
+
|
|
55
|
+
{regions.tools !== undefined && (
|
|
56
|
+
<div className="flex flex-col gap-3 empty:hidden">{regions.tools}</div>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
<div className="flex flex-col gap-3">{regions.posts}</div>
|
|
60
|
+
|
|
61
|
+
{regions.pagination}
|
|
62
|
+
|
|
63
|
+
{regions.afterContent !== undefined && (
|
|
64
|
+
<div className="flex flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-border pt-4 empty:hidden">
|
|
65
|
+
{regions.afterContent}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{regions.quickReply}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Avatar, cn } from '@meith/ui'
|
|
2
|
+
import { Menu } from '@meith/ui/menu'
|
|
3
|
+
import type { LinkModel, UserPanelModel } from '@meith/theme-kit'
|
|
4
|
+
|
|
5
|
+
import { BUTTON, MUTED_LINK } from '../shared'
|
|
6
|
+
|
|
7
|
+
const COUNT =
|
|
8
|
+
'inline-flex h-7 items-center gap-1 rounded-full px-2.5 text-xs font-bold tabular-nums uppercase tracking-[0.08em]'
|
|
9
|
+
|
|
10
|
+
function Count({
|
|
11
|
+
href,
|
|
12
|
+
className,
|
|
13
|
+
children,
|
|
14
|
+
}: {
|
|
15
|
+
href?: string | undefined
|
|
16
|
+
className: string
|
|
17
|
+
children: React.ReactNode
|
|
18
|
+
}) {
|
|
19
|
+
const badge = <span className={cn(COUNT, className)}>{children}</span>
|
|
20
|
+
if (href === undefined) return badge
|
|
21
|
+
return (
|
|
22
|
+
<a
|
|
23
|
+
href={href}
|
|
24
|
+
className="rounded-full outline-offset-2 focus-visible:outline-2 focus-visible:outline-ring"
|
|
25
|
+
>
|
|
26
|
+
{badge}
|
|
27
|
+
</a>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function UserPanel({
|
|
32
|
+
viewer,
|
|
33
|
+
links,
|
|
34
|
+
unreadNotifications,
|
|
35
|
+
unreadMessages,
|
|
36
|
+
notificationsHref,
|
|
37
|
+
messagesHref,
|
|
38
|
+
children,
|
|
39
|
+
}: UserPanelModel) {
|
|
40
|
+
if (viewer.isGuest) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex items-center gap-2">
|
|
43
|
+
{links.map((link: LinkModel, index: number) => (
|
|
44
|
+
<a
|
|
45
|
+
key={link.href}
|
|
46
|
+
href={link.href}
|
|
47
|
+
className={cn(
|
|
48
|
+
BUTTON,
|
|
49
|
+
index === 0
|
|
50
|
+
? 'bg-primary text-primary-foreground hover:bg-primary-hover'
|
|
51
|
+
: 'border border-border text-foreground hover:bg-accent',
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{link.label}
|
|
55
|
+
</a>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const name = viewer.username ?? 'Signed in'
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="flex min-w-0 flex-col items-start gap-1 sm:items-end">
|
|
65
|
+
<noscript
|
|
66
|
+
dangerouslySetInnerHTML={{
|
|
67
|
+
__html:
|
|
68
|
+
'<style>[data-account="menu"]{display:none}[data-account="plain"]{display:flex!important}</style>',
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
73
|
+
{unreadNotifications > 0 && (
|
|
74
|
+
<Count href={notificationsHref} className="bg-primary text-primary-foreground">
|
|
75
|
+
{unreadNotifications}
|
|
76
|
+
<span className="sr-only"> unread notifications</span>
|
|
77
|
+
<span aria-hidden="true">new</span>
|
|
78
|
+
</Count>
|
|
79
|
+
)}
|
|
80
|
+
{unreadMessages > 0 && (
|
|
81
|
+
<Count href={messagesHref} className="border border-border text-foreground">
|
|
82
|
+
{unreadMessages}
|
|
83
|
+
<span className="sr-only"> unread messages</span>
|
|
84
|
+
<span aria-hidden="true">unread</span>
|
|
85
|
+
</Count>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<span data-account="menu" className="flex min-w-0 items-center">
|
|
89
|
+
<Menu
|
|
90
|
+
label="Your account"
|
|
91
|
+
items={links}
|
|
92
|
+
triggerClassName="rounded-full px-2 py-1"
|
|
93
|
+
trigger={
|
|
94
|
+
<>
|
|
95
|
+
<Avatar
|
|
96
|
+
src={viewer.avatarUrl}
|
|
97
|
+
name={name}
|
|
98
|
+
size={26}
|
|
99
|
+
className="rounded-full border-primary/60"
|
|
100
|
+
/>
|
|
101
|
+
<span className="max-w-40 truncate text-sm font-semibold text-foreground">
|
|
102
|
+
{name}
|
|
103
|
+
</span>
|
|
104
|
+
<svg
|
|
105
|
+
aria-hidden="true"
|
|
106
|
+
viewBox="0 0 12 12"
|
|
107
|
+
className="size-3 shrink-0 text-muted-foreground"
|
|
108
|
+
fill="none"
|
|
109
|
+
stroke="currentColor"
|
|
110
|
+
strokeWidth="1.75"
|
|
111
|
+
strokeLinecap="round"
|
|
112
|
+
strokeLinejoin="round"
|
|
113
|
+
>
|
|
114
|
+
<path d="m3 4.5 3 3 3-3" />
|
|
115
|
+
</svg>
|
|
116
|
+
</>
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</Menu>
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<nav
|
|
125
|
+
data-account="plain"
|
|
126
|
+
style={{ display: 'none' }}
|
|
127
|
+
aria-label="Your account"
|
|
128
|
+
className="flex-wrap items-center gap-x-3 gap-y-1 text-xs sm:justify-end"
|
|
129
|
+
>
|
|
130
|
+
<span className="font-semibold text-foreground">{name}</span>
|
|
131
|
+
{links.map((link: LinkModel) => (
|
|
132
|
+
<a key={link.href} href={link.href} className={MUTED_LINK}>
|
|
133
|
+
{link.label}
|
|
134
|
+
</a>
|
|
135
|
+
))}
|
|
136
|
+
{children}
|
|
137
|
+
</nav>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Badge } from '@meith/ui'
|
|
2
|
+
import type { OnlineMemberModel, WhoIsOnlineModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { LINK, MICRO, NUMERIC, Stamp, UserRef } from '../shared'
|
|
5
|
+
|
|
6
|
+
const VISIBLE_NAMES = 12
|
|
7
|
+
|
|
8
|
+
export function WhoIsOnline({
|
|
9
|
+
guestCount,
|
|
10
|
+
members,
|
|
11
|
+
total,
|
|
12
|
+
recordCount,
|
|
13
|
+
recordAt,
|
|
14
|
+
fullListHref,
|
|
15
|
+
}: WhoIsOnlineModel) {
|
|
16
|
+
const shown = members.slice(0, VISIBLE_NAMES)
|
|
17
|
+
const rest = members.slice(VISIBLE_NAMES)
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<section
|
|
21
|
+
aria-labelledby="who-is-online-heading"
|
|
22
|
+
className="flex flex-col gap-1.5 text-xs text-muted-foreground"
|
|
23
|
+
>
|
|
24
|
+
<h2 id="who-is-online-heading" className={MICRO}>
|
|
25
|
+
In the clubhouse
|
|
26
|
+
</h2>
|
|
27
|
+
|
|
28
|
+
<p className={NUMERIC}>
|
|
29
|
+
<span className="font-semibold text-foreground">{total.toLocaleString('en')}</span> online —{' '}
|
|
30
|
+
{members.length.toLocaleString('en')} {members.length === 1 ? 'member' : 'members'},{' '}
|
|
31
|
+
{guestCount.toLocaleString('en')} {guestCount === 1 ? 'guest' : 'guests'}
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
{members.length === 0 ? (
|
|
35
|
+
<p>
|
|
36
|
+
{guestCount === 0
|
|
37
|
+
? 'Nobody is reading the board right now.'
|
|
38
|
+
: 'Only guests are reading the board right now.'}
|
|
39
|
+
</p>
|
|
40
|
+
) : (
|
|
41
|
+
<p className="min-w-0">
|
|
42
|
+
{shown.map((member, index) => (
|
|
43
|
+
<Name key={member.userId ?? member.username} member={member} first={index === 0} />
|
|
44
|
+
))}
|
|
45
|
+
|
|
46
|
+
{rest.length > 0 && (
|
|
47
|
+
<details className="inline">
|
|
48
|
+
<summary
|
|
49
|
+
className={`inline cursor-default list-none ${LINK} [&::-webkit-details-marker]:hidden [&::marker]:content-none`}
|
|
50
|
+
>
|
|
51
|
+
{' and '}
|
|
52
|
+
<span className={NUMERIC}>{rest.length.toLocaleString('en')}</span> more
|
|
53
|
+
</summary>
|
|
54
|
+
{': '}
|
|
55
|
+
{rest.map((member, index) => (
|
|
56
|
+
<Name key={member.userId ?? member.username} member={member} first={index === 0} />
|
|
57
|
+
))}
|
|
58
|
+
</details>
|
|
59
|
+
)}
|
|
60
|
+
</p>
|
|
61
|
+
)}
|
|
62
|
+
|
|
63
|
+
<p className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
|
64
|
+
<a href={fullListHref} className={`font-semibold text-foreground ${LINK}`}>
|
|
65
|
+
See everyone online
|
|
66
|
+
</a>
|
|
67
|
+
{recordAt !== null && (
|
|
68
|
+
<span className={`ms-auto ${NUMERIC}`}>
|
|
69
|
+
Record: {recordCount.toLocaleString('en')} on <Stamp at={recordAt} />
|
|
70
|
+
</span>
|
|
71
|
+
)}
|
|
72
|
+
</p>
|
|
73
|
+
</section>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function Name({ member, first }: { member: OnlineMemberModel; first: boolean }) {
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
{!first && ', '}
|
|
81
|
+
<UserRef user={member} className="font-medium" />
|
|
82
|
+
{member.isInvisible && (
|
|
83
|
+
<>
|
|
84
|
+
{' '}
|
|
85
|
+
<Badge tone="neutral">Invisible</Badge>
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</>
|
|
89
|
+
)
|
|
90
|
+
}
|