@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,107 @@
|
|
|
1
|
+
import { Card, CardRows, Empty, EmptyAction, EmptyDescription, EmptyTitle, cn } from '@meith/ui'
|
|
2
|
+
import type { ForumDisplayModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
BUTTON,
|
|
6
|
+
ColumnHeads,
|
|
7
|
+
HEADING,
|
|
8
|
+
MICRO,
|
|
9
|
+
NUMERIC,
|
|
10
|
+
PAGE_BODY,
|
|
11
|
+
PageHead,
|
|
12
|
+
isEmptyRegion,
|
|
13
|
+
} from '../shared'
|
|
14
|
+
|
|
15
|
+
export function ForumDisplay({ forum, newThreadHref, markReadAction, regions }: ForumDisplayModel) {
|
|
16
|
+
return (
|
|
17
|
+
<div className={PAGE_BODY}>
|
|
18
|
+
<PageHead
|
|
19
|
+
actions={
|
|
20
|
+
<>
|
|
21
|
+
{markReadAction !== null && (
|
|
22
|
+
<form action={markReadAction} method="post">
|
|
23
|
+
<button
|
|
24
|
+
type="submit"
|
|
25
|
+
className={cn(
|
|
26
|
+
BUTTON,
|
|
27
|
+
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
Mark read
|
|
31
|
+
</button>
|
|
32
|
+
</form>
|
|
33
|
+
)}
|
|
34
|
+
{newThreadHref !== null && (
|
|
35
|
+
<a
|
|
36
|
+
href={newThreadHref}
|
|
37
|
+
className={cn(BUTTON, 'bg-primary text-primary-foreground hover:bg-primary-hover')}
|
|
38
|
+
>
|
|
39
|
+
New thread
|
|
40
|
+
</a>
|
|
41
|
+
)}
|
|
42
|
+
</>
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
<h1 className={`${HEADING} text-xl text-balance`}>{forum.title}</h1>
|
|
46
|
+
<p className="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-xs text-muted-foreground">
|
|
47
|
+
{forum.type !== 'link' && (
|
|
48
|
+
<span className={`${MICRO} ${NUMERIC}`}>
|
|
49
|
+
{forum.threadCount.toLocaleString('en')} threads ·{' '}
|
|
50
|
+
{forum.postCount.toLocaleString('en')} posts
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
{forum.description !== null && <span>{forum.description}</span>}
|
|
54
|
+
</p>
|
|
55
|
+
</PageHead>
|
|
56
|
+
|
|
57
|
+
{regions.tools !== undefined && (
|
|
58
|
+
<div className="flex flex-col gap-3 empty:hidden">{regions.tools}</div>
|
|
59
|
+
)}
|
|
60
|
+
|
|
61
|
+
{regions.announcements !== undefined && (
|
|
62
|
+
<div className="flex flex-col gap-3">{regions.announcements}</div>
|
|
63
|
+
)}
|
|
64
|
+
|
|
65
|
+
{regions.subforums}
|
|
66
|
+
|
|
67
|
+
<Card>
|
|
68
|
+
{isEmptyRegion(regions.threads) ? (
|
|
69
|
+
<Empty>
|
|
70
|
+
<EmptyTitle>No threads here yet</EmptyTitle>
|
|
71
|
+
<EmptyDescription>
|
|
72
|
+
{newThreadHref === null
|
|
73
|
+
? 'Nothing has been posted in this forum.'
|
|
74
|
+
: 'Nothing has been posted in this forum. Yours would be the first.'}
|
|
75
|
+
</EmptyDescription>
|
|
76
|
+
{newThreadHref !== null && (
|
|
77
|
+
<EmptyAction>
|
|
78
|
+
<a
|
|
79
|
+
href={newThreadHref}
|
|
80
|
+
className={cn(
|
|
81
|
+
BUTTON,
|
|
82
|
+
'bg-primary text-primary-foreground hover:bg-primary-hover',
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
Start the first thread
|
|
86
|
+
</a>
|
|
87
|
+
</EmptyAction>
|
|
88
|
+
)}
|
|
89
|
+
</Empty>
|
|
90
|
+
) : (
|
|
91
|
+
<>
|
|
92
|
+
<ColumnHeads first="Thread" counts={['Replies', 'Views']} last="Latest" />
|
|
93
|
+
<CardRows>{regions.threads}</CardRows>
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
</Card>
|
|
97
|
+
|
|
98
|
+
{regions.pagination}
|
|
99
|
+
|
|
100
|
+
{regions.afterContent !== undefined && (
|
|
101
|
+
<div className="flex flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-border pt-4 empty:hidden">
|
|
102
|
+
{regions.afterContent}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ForumRowSlotModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { CHIP, HEADING, LINK, MICRO, ReadMark, Stamp, TABLE_ROW, Tally, 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={`${TABLE_ROW} transition-colors hover:bg-accent`}
|
|
12
|
+
>
|
|
13
|
+
<ReadMark unread={forum.isUnread} />
|
|
14
|
+
|
|
15
|
+
<div className="min-w-0">
|
|
16
|
+
<a
|
|
17
|
+
href={forum.href}
|
|
18
|
+
className={`${HEADING} text-sm ${forum.isUnread ? 'text-forum-unread' : 'text-forum-read'} ${LINK}`}
|
|
19
|
+
>
|
|
20
|
+
{forum.title}
|
|
21
|
+
</a>
|
|
22
|
+
{forum.isUnread && <span className="sr-only"> (new posts)</span>}
|
|
23
|
+
{isLink && <span className={`${MICRO} ml-2`}>Link</span>}
|
|
24
|
+
|
|
25
|
+
{forum.description !== null && (
|
|
26
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{forum.description}</p>
|
|
27
|
+
)}
|
|
28
|
+
|
|
29
|
+
{forum.subforums.length > 0 && (
|
|
30
|
+
<p className="mt-1.5 flex flex-wrap items-center gap-1">
|
|
31
|
+
{forum.subforums.map((sub) => (
|
|
32
|
+
<a key={sub.href} href={sub.href} className={CHIP}>
|
|
33
|
+
{sub.label}
|
|
34
|
+
</a>
|
|
35
|
+
))}
|
|
36
|
+
</p>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
{!isLink && (
|
|
41
|
+
<>
|
|
42
|
+
<span className="col-start-2 md:col-start-3 md:block md:text-right">
|
|
43
|
+
<Tally value={forum.threadCount} label="threads" />
|
|
44
|
+
<span className="ms-3 md:hidden">
|
|
45
|
+
<Tally value={forum.postCount} label="posts" />
|
|
46
|
+
</span>
|
|
47
|
+
</span>
|
|
48
|
+
|
|
49
|
+
<span className="hidden md:block md:text-right">
|
|
50
|
+
<Tally value={forum.postCount} label="posts" />
|
|
51
|
+
</span>
|
|
52
|
+
|
|
53
|
+
<div className="col-start-2 min-w-0 text-xs text-muted-foreground md:col-start-5">
|
|
54
|
+
{forum.lastPost === null ? (
|
|
55
|
+
<span className={MICRO}>No posts yet</span>
|
|
56
|
+
) : (
|
|
57
|
+
<>
|
|
58
|
+
<a
|
|
59
|
+
href={forum.lastPost.href}
|
|
60
|
+
className={`block truncate font-semibold text-foreground ${LINK}`}
|
|
61
|
+
>
|
|
62
|
+
{forum.lastPost.threadTitle}
|
|
63
|
+
</a>
|
|
64
|
+
<span className="mt-0.5 block truncate">
|
|
65
|
+
<UserRef user={forum.lastPost.author} className="font-medium" />
|
|
66
|
+
{' · '}
|
|
67
|
+
<Stamp at={forum.lastPost.at} />
|
|
68
|
+
</span>
|
|
69
|
+
</>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</>
|
|
73
|
+
)}
|
|
74
|
+
</li>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { HeaderModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { ClubRule, Crest, HEADING, PAGE, TAB } from '../shared'
|
|
4
|
+
|
|
5
|
+
function BoardMark({ boardTitle, logo }: Pick<HeaderModel, 'boardTitle' | 'logo'>) {
|
|
6
|
+
if (logo === undefined) {
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<Crest title={boardTitle} />
|
|
10
|
+
<span className={`${HEADING} text-lg leading-none text-balance sm:text-xl`}>
|
|
11
|
+
{boardTitle}
|
|
12
|
+
</span>
|
|
13
|
+
</>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const image = (
|
|
18
|
+
<img
|
|
19
|
+
src={logo.src}
|
|
20
|
+
alt={logo.alt}
|
|
21
|
+
className="h-9 w-auto max-w-48 object-contain"
|
|
22
|
+
decoding="async"
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if (logo.darkSrc === null) return image
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<picture>
|
|
30
|
+
<source media="(prefers-color-scheme: dark)" srcSet={logo.darkSrc} />
|
|
31
|
+
{image}
|
|
32
|
+
</picture>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function Header({ boardTitle, homeHref, navigation, logo, children }: HeaderModel) {
|
|
37
|
+
return (
|
|
38
|
+
<header className="bg-card">
|
|
39
|
+
<div className={`${PAGE} flex flex-wrap items-center justify-between gap-x-6 gap-y-3 py-3`}>
|
|
40
|
+
<a
|
|
41
|
+
href={homeHref}
|
|
42
|
+
className="inline-flex min-w-0 items-center gap-3 text-foreground underline-offset-4 hover:underline"
|
|
43
|
+
>
|
|
44
|
+
<BoardMark boardTitle={boardTitle} logo={logo} />
|
|
45
|
+
</a>
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<ClubRule />
|
|
50
|
+
|
|
51
|
+
{navigation.length > 0 && (
|
|
52
|
+
<nav aria-label="Board sections" className="border-b border-border bg-surface">
|
|
53
|
+
<div className={PAGE}>
|
|
54
|
+
<ul className="-mx-4 flex items-stretch overflow-x-auto px-4 sm:-mx-6 sm:px-6">
|
|
55
|
+
{navigation.map((item) => (
|
|
56
|
+
<li key={item.href} className="shrink-0">
|
|
57
|
+
<a
|
|
58
|
+
href={item.href}
|
|
59
|
+
className={`${TAB} border-b-2 border-b-transparent text-muted-foreground hover:border-b-secondary hover:bg-primary hover:text-primary-foreground`}
|
|
60
|
+
>
|
|
61
|
+
{item.label}
|
|
62
|
+
</a>
|
|
63
|
+
</li>
|
|
64
|
+
))}
|
|
65
|
+
</ul>
|
|
66
|
+
</div>
|
|
67
|
+
</nav>
|
|
68
|
+
)}
|
|
69
|
+
</header>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Card, CardContent, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
|
|
2
|
+
import type { LatestPostsModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { LINK, MICRO, MUTED_LINK, PanelHead, Stamp, UserRef } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function LatestPosts({ posts, capturedAt }: LatestPostsModel) {
|
|
7
|
+
return (
|
|
8
|
+
<Card aria-labelledby="latest-posts-heading">
|
|
9
|
+
<PanelHead
|
|
10
|
+
id="latest-posts-heading"
|
|
11
|
+
title="Latest posts"
|
|
12
|
+
aside={
|
|
13
|
+
<span className={MICRO}>
|
|
14
|
+
As of <Stamp at={capturedAt} />
|
|
15
|
+
</span>
|
|
16
|
+
}
|
|
17
|
+
/>
|
|
18
|
+
|
|
19
|
+
{posts.length === 0 ? (
|
|
20
|
+
<Empty className="py-5">
|
|
21
|
+
<EmptyTitle>Nothing said yet</EmptyTitle>
|
|
22
|
+
<EmptyDescription>The newest posts you can see will appear here.</EmptyDescription>
|
|
23
|
+
</Empty>
|
|
24
|
+
) : (
|
|
25
|
+
<CardContent className="px-0 py-0">
|
|
26
|
+
<ul className="divide-y divide-border">
|
|
27
|
+
{posts.map((post) => (
|
|
28
|
+
<li key={post.href} className="flex flex-col gap-0.5 px-4 py-2">
|
|
29
|
+
<a
|
|
30
|
+
href={post.href}
|
|
31
|
+
className={`truncate text-sm font-semibold text-foreground ${LINK}`}
|
|
32
|
+
>
|
|
33
|
+
{post.threadTitle}
|
|
34
|
+
</a>
|
|
35
|
+
|
|
36
|
+
{post.excerpt !== '' && (
|
|
37
|
+
<p className="line-clamp-2 text-xs text-muted-foreground">{post.excerpt}</p>
|
|
38
|
+
)}
|
|
39
|
+
|
|
40
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
41
|
+
<UserRef user={post.author} className="font-medium" />
|
|
42
|
+
{' in '}
|
|
43
|
+
<a href={post.forum.href} className={MUTED_LINK}>
|
|
44
|
+
{post.forum.label}
|
|
45
|
+
</a>
|
|
46
|
+
{' · '}
|
|
47
|
+
<Stamp at={post.postedAt} />
|
|
48
|
+
</p>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
</CardContent>
|
|
53
|
+
)}
|
|
54
|
+
</Card>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Card, CardContent, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
|
|
2
|
+
import type { LatestThreadsModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { LINK, MICRO, MUTED_LINK, NUMERIC, PanelHead, Stamp, UserRef } from '../shared'
|
|
5
|
+
|
|
6
|
+
export function LatestThreads({ threads, capturedAt }: LatestThreadsModel) {
|
|
7
|
+
return (
|
|
8
|
+
<Card aria-labelledby="latest-threads-heading">
|
|
9
|
+
<PanelHead
|
|
10
|
+
id="latest-threads-heading"
|
|
11
|
+
title="Latest threads"
|
|
12
|
+
aside={
|
|
13
|
+
<span className={MICRO}>
|
|
14
|
+
As of <Stamp at={capturedAt} />
|
|
15
|
+
</span>
|
|
16
|
+
}
|
|
17
|
+
/>
|
|
18
|
+
|
|
19
|
+
{threads.length === 0 ? (
|
|
20
|
+
<Empty className="py-5">
|
|
21
|
+
<EmptyTitle>Nothing started yet</EmptyTitle>
|
|
22
|
+
<EmptyDescription>The newest threads you can see will appear here.</EmptyDescription>
|
|
23
|
+
</Empty>
|
|
24
|
+
) : (
|
|
25
|
+
<CardContent className="px-0 py-0">
|
|
26
|
+
<ul className="divide-y divide-border">
|
|
27
|
+
{threads.map((thread) => (
|
|
28
|
+
<li key={thread.href} className="flex flex-col gap-0.5 px-4 py-2">
|
|
29
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
30
|
+
<a
|
|
31
|
+
href={thread.href}
|
|
32
|
+
className={`truncate text-sm font-semibold text-foreground ${LINK}`}
|
|
33
|
+
>
|
|
34
|
+
{thread.title}
|
|
35
|
+
</a>
|
|
36
|
+
<span className={`shrink-0 text-xs text-muted-foreground ${NUMERIC}`}>
|
|
37
|
+
{thread.replyCount.toLocaleString('en')}
|
|
38
|
+
</span>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
42
|
+
<UserRef user={thread.author} className="font-medium" />
|
|
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,118 @@
|
|
|
1
|
+
import { Avatar, Card, CardContent, cn } from '@meith/ui'
|
|
2
|
+
import type { MemberProfileModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { BUTTON, ClubBar, HEADING, MICRO, NUMERIC, PAGE_BODY, PanelHead, Stamp } 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={PAGE_BODY}>
|
|
20
|
+
<Card className="flex items-stretch">
|
|
21
|
+
<ClubBar />
|
|
22
|
+
|
|
23
|
+
<div className="min-w-0 flex-1">
|
|
24
|
+
<CardContent className="flex flex-col gap-4 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
|
25
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
26
|
+
<Avatar
|
|
27
|
+
src={avatarUrl}
|
|
28
|
+
name={user.username}
|
|
29
|
+
size={56}
|
|
30
|
+
className="rounded-sm border-border"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
<div className="min-w-0">
|
|
34
|
+
<h1 className={cn(HEADING, 'text-xl break-words', user.nameClass)}>
|
|
35
|
+
{user.username}
|
|
36
|
+
</h1>
|
|
37
|
+
{title !== null && <p className={`${MICRO} mt-0.5`}>{title}</p>}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
{actions.length > 0 && (
|
|
42
|
+
<nav
|
|
43
|
+
aria-label="Member actions"
|
|
44
|
+
className="flex shrink-0 flex-wrap items-center gap-2"
|
|
45
|
+
>
|
|
46
|
+
{actions.map((action, index) => (
|
|
47
|
+
<a
|
|
48
|
+
key={action.href}
|
|
49
|
+
href={action.href}
|
|
50
|
+
className={cn(
|
|
51
|
+
BUTTON,
|
|
52
|
+
index === 0
|
|
53
|
+
? 'bg-primary text-primary-foreground hover:bg-primary-hover'
|
|
54
|
+
: 'border border-border text-muted-foreground hover:bg-accent hover:text-foreground',
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{action.label}
|
|
58
|
+
</a>
|
|
59
|
+
))}
|
|
60
|
+
</nav>
|
|
61
|
+
)}
|
|
62
|
+
</CardContent>
|
|
63
|
+
|
|
64
|
+
<dl className="flex flex-wrap gap-x-6 gap-y-1 border-t-2 border-t-secondary bg-surface px-4 py-2.5 text-xs">
|
|
65
|
+
<div className="flex items-baseline gap-1.5">
|
|
66
|
+
<dt className={MICRO}>Posts</dt>
|
|
67
|
+
<dd className={`${NUMERIC} font-semibold text-foreground`}>
|
|
68
|
+
{postCount.toLocaleString('en')}
|
|
69
|
+
</dd>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="flex items-baseline gap-1.5">
|
|
72
|
+
<dt className={MICRO}>Joined</dt>
|
|
73
|
+
<dd className="font-semibold text-foreground">
|
|
74
|
+
<Stamp at={joinedAt} />
|
|
75
|
+
</dd>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="flex items-baseline gap-1.5">
|
|
78
|
+
<dt className={MICRO}>Last visit</dt>
|
|
79
|
+
<dd className="font-semibold text-foreground">
|
|
80
|
+
{lastVisitAt === null ? 'Never' : <Stamp at={lastVisitAt} />}
|
|
81
|
+
</dd>
|
|
82
|
+
</div>
|
|
83
|
+
</dl>
|
|
84
|
+
</div>
|
|
85
|
+
</Card>
|
|
86
|
+
|
|
87
|
+
{fields.length > 0 && (
|
|
88
|
+
<Card aria-labelledby="profile-fields-heading">
|
|
89
|
+
<PanelHead id="profile-fields-heading" title="About" />
|
|
90
|
+
<CardContent className="px-4 py-3">
|
|
91
|
+
<dl className="grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
|
|
92
|
+
{fields.map((field) => (
|
|
93
|
+
<div key={field.label}>
|
|
94
|
+
<dt className={MICRO}>{field.label}</dt>
|
|
95
|
+
<dd className="text-sm break-words">{field.value}</dd>
|
|
96
|
+
</div>
|
|
97
|
+
))}
|
|
98
|
+
</dl>
|
|
99
|
+
</CardContent>
|
|
100
|
+
</Card>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{signatureHtml !== null && (
|
|
104
|
+
<Card aria-labelledby="profile-signature-heading">
|
|
105
|
+
<PanelHead id="profile-signature-heading" title="Signature" />
|
|
106
|
+
<CardContent className="px-4 py-3">
|
|
107
|
+
<div
|
|
108
|
+
className="prose-md text-sm text-muted-foreground"
|
|
109
|
+
dangerouslySetInnerHTML={{ __html: signatureHtml }}
|
|
110
|
+
/>
|
|
111
|
+
</CardContent>
|
|
112
|
+
</Card>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{regions?.plugins}
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { NavigationModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { MICRO, 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">
|
|
10
|
+
<ol
|
|
11
|
+
className={`${PAGE} flex items-center gap-2 overflow-x-auto py-2 whitespace-nowrap ${MICRO}`}
|
|
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-2">
|
|
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-[26ch] truncate 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 { MICRO, 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 className={MICRO}>{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,74 @@
|
|
|
1
|
+
import { cn } from '@meith/ui'
|
|
2
|
+
import type { PaginationModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { BUTTON, MICRO, 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(BUTTON, 'min-w-20 justify-center border border-border bg-card hover:bg-accent')
|
|
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
|
+
BUTTON,
|
|
42
|
+
'min-w-8 justify-center px-2.5',
|
|
43
|
+
NUMERIC,
|
|
44
|
+
entry.isCurrent
|
|
45
|
+
? 'bg-primary text-primary-foreground'
|
|
46
|
+
: 'hidden text-muted-foreground hover:bg-accent hover:text-foreground sm:inline-flex',
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{entry.page}
|
|
50
|
+
</a>
|
|
51
|
+
</li>
|
|
52
|
+
)
|
|
53
|
+
})}
|
|
54
|
+
|
|
55
|
+
<li className={`ml-2 whitespace-nowrap ${MICRO}`}>
|
|
56
|
+
<span className="sr-only">Page </span>
|
|
57
|
+
<span className={NUMERIC}>
|
|
58
|
+
{page} of {pageCount}
|
|
59
|
+
</span>
|
|
60
|
+
</li>
|
|
61
|
+
</ol>
|
|
62
|
+
|
|
63
|
+
{nextHref === null ? (
|
|
64
|
+
<span className={stepDisabled} aria-hidden="true">
|
|
65
|
+
Next
|
|
66
|
+
</span>
|
|
67
|
+
) : (
|
|
68
|
+
<a href={nextHref} rel="next" className={step}>
|
|
69
|
+
Next
|
|
70
|
+
</a>
|
|
71
|
+
)}
|
|
72
|
+
</nav>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Separator, cn } from '@meith/ui'
|
|
2
|
+
import type { PostActionsSlotModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { BUTTON, isEmptyRegion } from '../shared'
|
|
5
|
+
|
|
6
|
+
interface Action {
|
|
7
|
+
readonly href: string
|
|
8
|
+
readonly label: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const ACTION = cn(BUTTON, 'h-7 px-2.5 text-muted-foreground hover:bg-accent hover:text-foreground')
|
|
12
|
+
|
|
13
|
+
export function PostActions({ actions, children }: PostActionsSlotModel) {
|
|
14
|
+
const reader: Action[] = [
|
|
15
|
+
actions.quoteHref === null ? null : { href: actions.quoteHref, label: 'Quote' },
|
|
16
|
+
actions.editHref === null ? null : { href: actions.editHref, label: 'Edit' },
|
|
17
|
+
actions.rateHref === null ? null : { href: actions.rateHref, label: 'Rate' },
|
|
18
|
+
actions.reportHref === null ? null : { href: actions.reportHref, label: 'Report' },
|
|
19
|
+
].filter((action): action is Action => action !== null)
|
|
20
|
+
|
|
21
|
+
const staff: Action[] = [
|
|
22
|
+
actions.restoreHref === null ? null : { href: actions.restoreHref, label: 'Restore' },
|
|
23
|
+
actions.warnHref === null ? null : { href: actions.warnHref, label: 'Warn' },
|
|
24
|
+
actions.moderateHref === null ? null : { href: actions.moderateHref, label: 'Moderate' },
|
|
25
|
+
].filter((action): action is Action => action !== null)
|
|
26
|
+
|
|
27
|
+
const extra = isEmptyRegion(children) ? null : children
|
|
28
|
+
|
|
29
|
+
if (reader.length === 0 && staff.length === 0 && extra === null) return null
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<nav
|
|
33
|
+
aria-label="Post actions"
|
|
34
|
+
className="flex flex-wrap items-center gap-1 border-t border-border bg-surface px-3 py-1.5 empty:hidden"
|
|
35
|
+
>
|
|
36
|
+
{reader.map((action) => (
|
|
37
|
+
<a key={action.href} href={action.href} className={ACTION}>
|
|
38
|
+
{action.label}
|
|
39
|
+
</a>
|
|
40
|
+
))}
|
|
41
|
+
|
|
42
|
+
{reader.length > 0 && staff.length > 0 && (
|
|
43
|
+
<Separator orientation="vertical" className="mx-1.5" />
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{staff.map((action) => (
|
|
47
|
+
<a key={action.href} href={action.href} className={ACTION}>
|
|
48
|
+
{action.label}
|
|
49
|
+
</a>
|
|
50
|
+
))}
|
|
51
|
+
|
|
52
|
+
{extra}
|
|
53
|
+
</nav>
|
|
54
|
+
)
|
|
55
|
+
}
|