@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,46 @@
|
|
|
1
|
+
import { Label, NativeSelect, buttonVariants } from '@meith/ui'
|
|
2
|
+
import type { ForumJumpModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { PAGE } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function ForumJump({ action, field, forums, submitLabel, label }: ForumJumpModel) {
|
|
7
|
+
if (forums.length === 0) return null
|
|
8
|
+
|
|
9
|
+
const id = `forum-jump-${field}`
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="border-t border-border bg-card">
|
|
13
|
+
<form
|
|
14
|
+
method="get"
|
|
15
|
+
action={action}
|
|
16
|
+
className={`${PAGE} flex flex-wrap items-center gap-2 py-3 sm:justify-end`}
|
|
17
|
+
>
|
|
18
|
+
<Label htmlFor={id} className="text-xs text-muted-foreground">
|
|
19
|
+
{label}
|
|
20
|
+
</Label>
|
|
21
|
+
|
|
22
|
+
<NativeSelect
|
|
23
|
+
id={id}
|
|
24
|
+
name={field}
|
|
25
|
+
defaultValue={forums.find((forum) => forum.isSelected)?.value ?? ''}
|
|
26
|
+
className="h-8 w-auto min-w-48 flex-1 text-xs sm:flex-none"
|
|
27
|
+
>
|
|
28
|
+
{forums.map((forum) => (
|
|
29
|
+
<option
|
|
30
|
+
key={forum.value}
|
|
31
|
+
value={forum.value}
|
|
32
|
+
disabled={forum.isCategory}
|
|
33
|
+
label={`${' '.repeat(forum.depth)}${forum.label}`}
|
|
34
|
+
>
|
|
35
|
+
{`${' '.repeat(forum.depth)}${forum.label}`}
|
|
36
|
+
</option>
|
|
37
|
+
))}
|
|
38
|
+
</NativeSelect>
|
|
39
|
+
|
|
40
|
+
<button type="submit" className={buttonVariants({ variant: 'outline', size: 'sm' })}>
|
|
41
|
+
{submitLabel}
|
|
42
|
+
</button>
|
|
43
|
+
</form>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ForumRowSlotModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { Counts, LINK, MUTED_LINK, ReadSpacer, Stamp, UnreadDot, UserRef } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function ForumRow({ forum }: ForumRowSlotModel) {
|
|
6
|
+
const isLink = forum.type === 'link'
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<li
|
|
10
|
+
data-unread={forum.isUnread ? '' : undefined}
|
|
11
|
+
className={
|
|
12
|
+
'grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-2 px-4 py-3 transition-colors hover:bg-muted/60' +
|
|
13
|
+
(isLink ? '' : ' md:grid-cols-[auto_minmax(0,1fr)_9rem_15rem] md:items-center md:gap-x-4')
|
|
14
|
+
}
|
|
15
|
+
>
|
|
16
|
+
{forum.isUnread ? <UnreadDot /> : <ReadSpacer />}
|
|
17
|
+
|
|
18
|
+
<div className="min-w-0">
|
|
19
|
+
<a
|
|
20
|
+
href={forum.href}
|
|
21
|
+
className={
|
|
22
|
+
(forum.isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground') +
|
|
23
|
+
` ${LINK}`
|
|
24
|
+
}
|
|
25
|
+
>
|
|
26
|
+
{forum.title}
|
|
27
|
+
</a>
|
|
28
|
+
{forum.isUnread && <span className="sr-only"> (new posts)</span>}
|
|
29
|
+
|
|
30
|
+
{forum.description !== null && (
|
|
31
|
+
<p className="mt-0.5 text-sm text-muted-foreground">{forum.description}</p>
|
|
32
|
+
)}
|
|
33
|
+
|
|
34
|
+
{forum.subforums.length > 0 && (
|
|
35
|
+
<p className="mt-1.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
|
|
36
|
+
<span className="font-medium text-foreground">Subforums</span>
|
|
37
|
+
{forum.subforums.map((sub) => (
|
|
38
|
+
<a key={sub.href} href={sub.href} className={MUTED_LINK}>
|
|
39
|
+
{sub.label}
|
|
40
|
+
</a>
|
|
41
|
+
))}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{!isLink && (
|
|
47
|
+
<>
|
|
48
|
+
<Counts
|
|
49
|
+
className="col-start-2 md:col-start-3 md:justify-end"
|
|
50
|
+
items={[
|
|
51
|
+
{
|
|
52
|
+
label: 'Threads',
|
|
53
|
+
value: forum.threadCount,
|
|
54
|
+
one: 'thread',
|
|
55
|
+
many: 'threads',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
label: 'Posts',
|
|
59
|
+
value: forum.postCount,
|
|
60
|
+
one: 'post',
|
|
61
|
+
many: 'posts',
|
|
62
|
+
},
|
|
63
|
+
]}
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
<div className="col-start-2 min-w-0 text-xs text-muted-foreground md:col-start-4">
|
|
67
|
+
{forum.lastPost === null ? (
|
|
68
|
+
<span className="text-forum-read">No posts yet</span>
|
|
69
|
+
) : (
|
|
70
|
+
<>
|
|
71
|
+
<a
|
|
72
|
+
href={forum.lastPost.href}
|
|
73
|
+
className={`block truncate font-medium text-foreground ${LINK}`}
|
|
74
|
+
>
|
|
75
|
+
{forum.lastPost.threadTitle}
|
|
76
|
+
</a>
|
|
77
|
+
<span className="mt-0.5 block truncate">
|
|
78
|
+
{'by '}
|
|
79
|
+
<UserRef user={forum.lastPost.author} className="font-normal" />
|
|
80
|
+
{' · '}
|
|
81
|
+
<Stamp at={forum.lastPost.at} />
|
|
82
|
+
</span>
|
|
83
|
+
</>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</li>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { HeaderModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { LINK, PAGE } from '../shared'
|
|
4
|
+
|
|
5
|
+
function BoardMark({ boardTitle, logo }: Pick<HeaderModel, 'boardTitle' | 'logo'>) {
|
|
6
|
+
if (logo === undefined) return <>{boardTitle}</>
|
|
7
|
+
|
|
8
|
+
const image = (
|
|
9
|
+
<img
|
|
10
|
+
src={logo.src}
|
|
11
|
+
alt={logo.alt}
|
|
12
|
+
className="h-8 w-auto max-w-48 object-contain"
|
|
13
|
+
decoding="async"
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if (logo.darkSrc === null) return image
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<picture>
|
|
21
|
+
<source media="(prefers-color-scheme: dark)" srcSet={logo.darkSrc} />
|
|
22
|
+
{image}
|
|
23
|
+
</picture>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Header({ boardTitle, homeHref, navigation, logo, children }: HeaderModel) {
|
|
28
|
+
return (
|
|
29
|
+
<header className="border-b border-border bg-card">
|
|
30
|
+
<div className={`${PAGE} flex flex-wrap items-center justify-between gap-x-6 gap-y-3 py-3`}>
|
|
31
|
+
<a
|
|
32
|
+
href={homeHref}
|
|
33
|
+
className={`inline-flex items-center text-xl font-semibold tracking-tight text-foreground ${LINK}`}
|
|
34
|
+
>
|
|
35
|
+
<BoardMark boardTitle={boardTitle} logo={logo} />
|
|
36
|
+
</a>
|
|
37
|
+
{children}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
{navigation.length > 0 && (
|
|
41
|
+
<nav aria-label="Board sections" className="border-t border-border bg-surface">
|
|
42
|
+
<div className={PAGE}>
|
|
43
|
+
<ul className="-mx-4 flex items-stretch gap-1 overflow-x-auto px-4 text-sm sm:-mx-6 sm:px-6">
|
|
44
|
+
{navigation.map((item) => (
|
|
45
|
+
<li key={item.href} className="shrink-0">
|
|
46
|
+
<a
|
|
47
|
+
href={item.href}
|
|
48
|
+
className="inline-flex h-10 items-center rounded-t-md border-b-2 border-transparent px-2.5 font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
|
49
|
+
>
|
|
50
|
+
{item.label}
|
|
51
|
+
</a>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
</div>
|
|
56
|
+
</nav>
|
|
57
|
+
)}
|
|
58
|
+
</header>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
|
|
2
|
+
import type { LatestPostsModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { LINK, MUTED_LINK, NUMERIC, Stamp, UserRef } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function LatestPosts({ posts, capturedAt }: LatestPostsModel) {
|
|
7
|
+
return (
|
|
8
|
+
<Card aria-labelledby="latest-posts-heading">
|
|
9
|
+
<CardHeader>
|
|
10
|
+
<CardTitle id="latest-posts-heading">Latest posts</CardTitle>
|
|
11
|
+
<p className={`text-xs text-muted-foreground ${NUMERIC}`}>
|
|
12
|
+
As of <Stamp at={capturedAt} />
|
|
13
|
+
</p>
|
|
14
|
+
</CardHeader>
|
|
15
|
+
|
|
16
|
+
{posts.length === 0 ? (
|
|
17
|
+
<Empty className="py-6">
|
|
18
|
+
<EmptyTitle>Nothing said yet</EmptyTitle>
|
|
19
|
+
<EmptyDescription>The newest posts you can see will appear here.</EmptyDescription>
|
|
20
|
+
</Empty>
|
|
21
|
+
) : (
|
|
22
|
+
<CardContent className="px-0 py-0">
|
|
23
|
+
<ul className="divide-y divide-border">
|
|
24
|
+
{posts.map((post) => (
|
|
25
|
+
<li key={post.href} className="flex flex-col gap-0.5 px-4 py-2">
|
|
26
|
+
<a
|
|
27
|
+
href={post.href}
|
|
28
|
+
className={`truncate text-sm font-medium text-foreground ${LINK}`}
|
|
29
|
+
>
|
|
30
|
+
{post.threadTitle}
|
|
31
|
+
</a>
|
|
32
|
+
|
|
33
|
+
{post.excerpt !== '' && (
|
|
34
|
+
<p className="line-clamp-2 text-xs text-muted-foreground">{post.excerpt}</p>
|
|
35
|
+
)}
|
|
36
|
+
|
|
37
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
38
|
+
<UserRef user={post.author} className="font-normal" />
|
|
39
|
+
{' in '}
|
|
40
|
+
<a href={post.forum.href} className={MUTED_LINK}>
|
|
41
|
+
{post.forum.label}
|
|
42
|
+
</a>
|
|
43
|
+
{' · '}
|
|
44
|
+
<Stamp at={post.postedAt} />
|
|
45
|
+
</p>
|
|
46
|
+
</li>
|
|
47
|
+
))}
|
|
48
|
+
</ul>
|
|
49
|
+
</CardContent>
|
|
50
|
+
)}
|
|
51
|
+
</Card>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Card, CardContent, CardHeader, CardTitle, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
|
|
2
|
+
import type { LatestThreadsModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { LINK, MUTED_LINK, NUMERIC, Stamp, UserRef } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function LatestThreads({ threads, capturedAt }: LatestThreadsModel) {
|
|
7
|
+
return (
|
|
8
|
+
<Card aria-labelledby="latest-threads-heading">
|
|
9
|
+
<CardHeader>
|
|
10
|
+
<CardTitle id="latest-threads-heading">Latest threads</CardTitle>
|
|
11
|
+
<p className={`text-xs text-muted-foreground ${NUMERIC}`}>
|
|
12
|
+
As of <Stamp at={capturedAt} />
|
|
13
|
+
</p>
|
|
14
|
+
</CardHeader>
|
|
15
|
+
|
|
16
|
+
{threads.length === 0 ? (
|
|
17
|
+
<Empty className="py-6">
|
|
18
|
+
<EmptyTitle>Nothing started yet</EmptyTitle>
|
|
19
|
+
<EmptyDescription>
|
|
20
|
+
The newest threads you can see will appear here.
|
|
21
|
+
</EmptyDescription>
|
|
22
|
+
</Empty>
|
|
23
|
+
) : (
|
|
24
|
+
<CardContent className="px-0 py-0">
|
|
25
|
+
<ul className="divide-y divide-border">
|
|
26
|
+
{threads.map((thread) => (
|
|
27
|
+
<li key={thread.href} className="flex flex-col gap-0.5 px-4 py-2">
|
|
28
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
29
|
+
<a
|
|
30
|
+
href={thread.href}
|
|
31
|
+
className={`truncate text-sm font-medium text-foreground ${LINK}`}
|
|
32
|
+
>
|
|
33
|
+
{thread.title}
|
|
34
|
+
</a>
|
|
35
|
+
<span className={`shrink-0 text-xs text-muted-foreground ${NUMERIC}`}>
|
|
36
|
+
{thread.replyCount.toLocaleString('en')}{' '}
|
|
37
|
+
{thread.replyCount === 1 ? 'reply' : 'replies'}
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
42
|
+
<UserRef user={thread.author} className="font-normal" />
|
|
43
|
+
{' in '}
|
|
44
|
+
<a href={thread.forum.href} className={MUTED_LINK}>
|
|
45
|
+
{thread.forum.label}
|
|
46
|
+
</a>
|
|
47
|
+
{' · '}
|
|
48
|
+
<Stamp at={thread.startedAt} />
|
|
49
|
+
</p>
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</CardContent>
|
|
54
|
+
)}
|
|
55
|
+
</Card>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Avatar, Card, CardContent, CardHeader, CardTitle, buttonVariants, cn } from '@meith/ui'
|
|
2
|
+
import type { MemberProfileModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { NUMERIC, Stamp, pageAt } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function MemberProfile({
|
|
7
|
+
user,
|
|
8
|
+
avatarUrl,
|
|
9
|
+
title,
|
|
10
|
+
joinedAt,
|
|
11
|
+
lastVisitAt,
|
|
12
|
+
postCount,
|
|
13
|
+
signatureHtml,
|
|
14
|
+
fields,
|
|
15
|
+
actions,
|
|
16
|
+
regions,
|
|
17
|
+
}: MemberProfileModel) {
|
|
18
|
+
return (
|
|
19
|
+
<div className={`${pageAt('max-w-4xl')} flex w-full flex-col gap-4 py-6 sm:py-8`}>
|
|
20
|
+
<Card>
|
|
21
|
+
<CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between">
|
|
22
|
+
<div className="flex min-w-0 items-center gap-4">
|
|
23
|
+
<Avatar src={avatarUrl} name={user.username} size={72} />
|
|
24
|
+
|
|
25
|
+
<div className="min-w-0">
|
|
26
|
+
<h1
|
|
27
|
+
className={cn(
|
|
28
|
+
'text-2xl font-semibold tracking-tight break-words',
|
|
29
|
+
user.nameClass,
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
{user.username}
|
|
33
|
+
</h1>
|
|
34
|
+
{title !== null && <p className="mt-0.5 text-sm text-muted-foreground">{title}</p>}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{actions.length > 0 && (
|
|
39
|
+
<nav aria-label="Member actions" className="flex shrink-0 flex-wrap items-center gap-2">
|
|
40
|
+
{actions.map((action, index) => (
|
|
41
|
+
<a
|
|
42
|
+
key={action.href}
|
|
43
|
+
href={action.href}
|
|
44
|
+
className={buttonVariants({
|
|
45
|
+
variant: index === 0 ? 'primary' : 'outline',
|
|
46
|
+
size: 'sm',
|
|
47
|
+
})}
|
|
48
|
+
>
|
|
49
|
+
{action.label}
|
|
50
|
+
</a>
|
|
51
|
+
))}
|
|
52
|
+
</nav>
|
|
53
|
+
)}
|
|
54
|
+
</CardContent>
|
|
55
|
+
|
|
56
|
+
<dl className="grid grid-cols-3 gap-3 border-t border-border px-5 py-4">
|
|
57
|
+
<div>
|
|
58
|
+
<dd className={`text-lg font-semibold text-foreground ${NUMERIC}`}>
|
|
59
|
+
{postCount.toLocaleString('en')}
|
|
60
|
+
</dd>
|
|
61
|
+
<dt className="text-xs text-muted-foreground">Posts</dt>
|
|
62
|
+
</div>
|
|
63
|
+
<div>
|
|
64
|
+
<dd className="text-sm font-medium text-foreground">
|
|
65
|
+
<Stamp at={joinedAt} />
|
|
66
|
+
</dd>
|
|
67
|
+
<dt className="text-xs text-muted-foreground">Joined</dt>
|
|
68
|
+
</div>
|
|
69
|
+
<div>
|
|
70
|
+
<dd className="text-sm font-medium text-foreground">
|
|
71
|
+
{lastVisitAt === null ? 'Never' : <Stamp at={lastVisitAt} />}
|
|
72
|
+
</dd>
|
|
73
|
+
<dt className="text-xs text-muted-foreground">Last visit</dt>
|
|
74
|
+
</div>
|
|
75
|
+
</dl>
|
|
76
|
+
</Card>
|
|
77
|
+
|
|
78
|
+
{fields.length > 0 && (
|
|
79
|
+
<Card aria-labelledby="profile-fields-heading">
|
|
80
|
+
<CardHeader>
|
|
81
|
+
<CardTitle id="profile-fields-heading" className="text-sm">
|
|
82
|
+
About
|
|
83
|
+
</CardTitle>
|
|
84
|
+
</CardHeader>
|
|
85
|
+
<CardContent>
|
|
86
|
+
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
|
87
|
+
{fields.map((field) => (
|
|
88
|
+
<div key={field.label}>
|
|
89
|
+
<dt className="text-xs text-muted-foreground">{field.label}</dt>
|
|
90
|
+
<dd className="text-sm break-words">{field.value}</dd>
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</dl>
|
|
94
|
+
</CardContent>
|
|
95
|
+
</Card>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{signatureHtml !== null && (
|
|
99
|
+
<Card aria-labelledby="profile-signature-heading">
|
|
100
|
+
<CardHeader>
|
|
101
|
+
<CardTitle id="profile-signature-heading" className="text-sm">
|
|
102
|
+
Signature
|
|
103
|
+
</CardTitle>
|
|
104
|
+
</CardHeader>
|
|
105
|
+
<CardContent>
|
|
106
|
+
<div
|
|
107
|
+
className="prose-md text-sm text-muted-foreground"
|
|
108
|
+
dangerouslySetInnerHTML={{ __html: signatureHtml }}
|
|
109
|
+
/>
|
|
110
|
+
</CardContent>
|
|
111
|
+
</Card>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{regions?.plugins}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { NavigationModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { MUTED_LINK, PAGE } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function Navigation({ items }: NavigationModel) {
|
|
6
|
+
if (items.length === 0) return null
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<nav aria-label="Breadcrumb" className="border-b border-border bg-card/40">
|
|
10
|
+
<ol
|
|
11
|
+
className={`${PAGE} flex items-center gap-1.5 overflow-x-auto py-2 text-xs whitespace-nowrap text-muted-foreground`}
|
|
12
|
+
>
|
|
13
|
+
{items.map((item, index) => {
|
|
14
|
+
const isLast = index === items.length - 1
|
|
15
|
+
return (
|
|
16
|
+
<li key={item.href} className="flex shrink-0 items-center gap-1.5">
|
|
17
|
+
{index > 0 && (
|
|
18
|
+
<span aria-hidden="true" className="text-border select-none">
|
|
19
|
+
/
|
|
20
|
+
</span>
|
|
21
|
+
)}
|
|
22
|
+
{isLast ? (
|
|
23
|
+
<span
|
|
24
|
+
aria-current="page"
|
|
25
|
+
className="max-w-[24ch] truncate font-medium text-foreground sm:max-w-none"
|
|
26
|
+
>
|
|
27
|
+
{item.label}
|
|
28
|
+
</span>
|
|
29
|
+
) : (
|
|
30
|
+
<a href={item.href} className={MUTED_LINK}>
|
|
31
|
+
{item.label}
|
|
32
|
+
</a>
|
|
33
|
+
)}
|
|
34
|
+
</li>
|
|
35
|
+
)
|
|
36
|
+
})}
|
|
37
|
+
</ol>
|
|
38
|
+
</nav>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Alert, AlertDescription, AlertTitle } from '@meith/ui'
|
|
2
|
+
import type { NoticeModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { MUTED_LINK } from '../shared'
|
|
5
|
+
|
|
6
|
+
const KIND_LABELS: Record<NoticeModel['kind'], string> = {
|
|
7
|
+
info: 'Notice:',
|
|
8
|
+
success: 'Done:',
|
|
9
|
+
warning: 'Warning:',
|
|
10
|
+
error: 'Error:',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Notice({ kind, message, dismissHref }: NoticeModel) {
|
|
14
|
+
return (
|
|
15
|
+
<Alert tone={kind}>
|
|
16
|
+
<AlertDescription>
|
|
17
|
+
<AlertTitle>{KIND_LABELS[kind]}</AlertTitle> {message}
|
|
18
|
+
</AlertDescription>
|
|
19
|
+
|
|
20
|
+
{dismissHref !== null && (
|
|
21
|
+
<a href={dismissHref} className={`shrink-0 text-xs ${MUTED_LINK}`}>
|
|
22
|
+
Dismiss
|
|
23
|
+
</a>
|
|
24
|
+
)}
|
|
25
|
+
</Alert>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { buttonVariants, cn } from '@meith/ui'
|
|
2
|
+
import type { PaginationModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { NUMERIC } 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 step = cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'min-w-20')
|
|
10
|
+
const stepDisabled = cn(step, 'pointer-events-none opacity-40')
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<nav aria-label="Pagination" className="flex flex-wrap items-center justify-between gap-3">
|
|
14
|
+
{previousHref === null ? (
|
|
15
|
+
<span className={stepDisabled} aria-hidden="true">
|
|
16
|
+
Previous
|
|
17
|
+
</span>
|
|
18
|
+
) : (
|
|
19
|
+
<a href={previousHref} rel="prev" className={step}>
|
|
20
|
+
Previous
|
|
21
|
+
</a>
|
|
22
|
+
)}
|
|
23
|
+
|
|
24
|
+
<ol className="flex items-center gap-1">
|
|
25
|
+
{pages.map((entry, index) => {
|
|
26
|
+
const previous = pages[index - 1]
|
|
27
|
+
const gap = previous !== undefined && entry.page - previous.page > 1
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<li key={entry.page} className="flex items-center gap-1">
|
|
31
|
+
{gap && (
|
|
32
|
+
<span aria-hidden="true" className="px-1 text-sm text-muted-foreground">
|
|
33
|
+
…
|
|
34
|
+
</span>
|
|
35
|
+
)}
|
|
36
|
+
<a
|
|
37
|
+
href={entry.href}
|
|
38
|
+
aria-current={entry.isCurrent ? 'page' : undefined}
|
|
39
|
+
aria-label={`Page ${entry.page}`}
|
|
40
|
+
className={cn(
|
|
41
|
+
buttonVariants({
|
|
42
|
+
variant: entry.isCurrent ? 'primary' : 'ghost',
|
|
43
|
+
size: 'sm',
|
|
44
|
+
}),
|
|
45
|
+
'min-w-8 px-2',
|
|
46
|
+
NUMERIC,
|
|
47
|
+
entry.isCurrent ? '' : 'hidden 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">
|
|
57
|
+
<span className="sr-only">Page </span>
|
|
58
|
+
<span className={NUMERIC}>
|
|
59
|
+
{page} of {pageCount}
|
|
60
|
+
</span>
|
|
61
|
+
</li>
|
|
62
|
+
</ol>
|
|
63
|
+
|
|
64
|
+
{nextHref === null ? (
|
|
65
|
+
<span className={stepDisabled} aria-hidden="true">
|
|
66
|
+
Next
|
|
67
|
+
</span>
|
|
68
|
+
) : (
|
|
69
|
+
<a href={nextHref} rel="next" className={step}>
|
|
70
|
+
Next
|
|
71
|
+
</a>
|
|
72
|
+
)}
|
|
73
|
+
</nav>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Separator, buttonVariants } from '@meith/ui'
|
|
2
|
+
import type { PostActionsSlotModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
interface Action {
|
|
5
|
+
readonly href: string
|
|
6
|
+
readonly label: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function PostActions({ actions, children }: PostActionsSlotModel) {
|
|
10
|
+
const reader: Action[] = [
|
|
11
|
+
actions.quoteHref === null ? null : { href: actions.quoteHref, label: 'Quote' },
|
|
12
|
+
actions.editHref === null ? null : { href: actions.editHref, label: 'Edit' },
|
|
13
|
+
actions.rateHref === null ? null : { href: actions.rateHref, label: 'Rate' },
|
|
14
|
+
actions.reportHref === null ? null : { href: actions.reportHref, label: 'Report' },
|
|
15
|
+
].filter((action): action is Action => action !== null)
|
|
16
|
+
|
|
17
|
+
const staff: Action[] = [
|
|
18
|
+
actions.restoreHref === null ? null : { href: actions.restoreHref, label: 'Restore' },
|
|
19
|
+
actions.warnHref === null ? null : { href: actions.warnHref, label: 'Warn' },
|
|
20
|
+
actions.moderateHref === null ? null : { href: actions.moderateHref, label: 'Moderate' },
|
|
21
|
+
].filter((action): action is Action => action !== null)
|
|
22
|
+
|
|
23
|
+
if (reader.length === 0 && staff.length === 0 && children === undefined) return null
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<nav aria-label="Post actions" className="flex flex-wrap items-center gap-x-1 gap-y-1">
|
|
27
|
+
{reader.map((action) => (
|
|
28
|
+
<a
|
|
29
|
+
key={action.href}
|
|
30
|
+
href={action.href}
|
|
31
|
+
className={buttonVariants({ variant: 'ghost', size: 'sm' })}
|
|
32
|
+
>
|
|
33
|
+
{action.label}
|
|
34
|
+
</a>
|
|
35
|
+
))}
|
|
36
|
+
|
|
37
|
+
{reader.length > 0 && staff.length > 0 && (
|
|
38
|
+
<Separator orientation="vertical" className="mx-1.5" />
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
{staff.map((action) => (
|
|
42
|
+
<a
|
|
43
|
+
key={action.href}
|
|
44
|
+
href={action.href}
|
|
45
|
+
className={buttonVariants({ variant: 'ghost', size: 'sm' })}
|
|
46
|
+
>
|
|
47
|
+
{action.label}
|
|
48
|
+
</a>
|
|
49
|
+
))}
|
|
50
|
+
|
|
51
|
+
{children !== undefined && <span className="ms-auto empty:hidden">{children}</span>}
|
|
52
|
+
</nav>
|
|
53
|
+
)
|
|
54
|
+
}
|