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