@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.
@@ -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
+ }