@meith/theme-phasebook 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 +33 -0
- package/src/index.ts +3 -0
- package/src/shared.tsx +185 -0
- package/src/slots/announcement.tsx +37 -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 +29 -0
- package/src/slots/footer.tsx +31 -0
- package/src/slots/forum-display.tsx +108 -0
- package/src/slots/forum-jump.tsx +48 -0
- package/src/slots/forum-row.tsx +79 -0
- package/src/slots/header.tsx +90 -0
- package/src/slots/latest-posts.tsx +58 -0
- package/src/slots/latest-threads.tsx +55 -0
- package/src/slots/member-profile.tsx +130 -0
- package/src/slots/navigation.tsx +43 -0
- package/src/slots/notice.tsx +62 -0
- package/src/slots/pagination.tsx +73 -0
- package/src/slots/post-actions.tsx +45 -0
- package/src/slots/post-bit.tsx +243 -0
- package/src/slots/post-form.tsx +41 -0
- package/src/slots/redirect-notice.tsx +24 -0
- package/src/slots/search-form.tsx +99 -0
- package/src/slots/shell.tsx +18 -0
- package/src/slots/subforum-list.tsx +39 -0
- package/src/slots/thread-row.tsx +81 -0
- package/src/slots/thread-view.tsx +68 -0
- package/src/slots/user-panel.tsx +154 -0
- package/src/slots/who-is-online.tsx +87 -0
- package/src/theme.ts +72 -0
- package/src/tokens.ts +102 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { ForumDisplayModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Circle,
|
|
5
|
+
FEED,
|
|
6
|
+
NUMERIC,
|
|
7
|
+
PAGE,
|
|
8
|
+
PILL,
|
|
9
|
+
PILL_PRIMARY,
|
|
10
|
+
count,
|
|
11
|
+
isEmptyRegion,
|
|
12
|
+
plural,
|
|
13
|
+
} from '../shared'
|
|
14
|
+
|
|
15
|
+
export function ForumDisplay({ forum, newThreadHref, markReadAction, regions }: ForumDisplayModel) {
|
|
16
|
+
return (
|
|
17
|
+
<div className={`${PAGE} py-4 sm:py-6`}>
|
|
18
|
+
<div className={`${FEED} flex flex-col gap-4`}>
|
|
19
|
+
<section className="overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
|
|
20
|
+
<div className="h-24 bg-primary/12" />
|
|
21
|
+
|
|
22
|
+
<div className="px-4 pb-4">
|
|
23
|
+
<div className="-mt-10 flex flex-wrap items-end justify-between gap-x-4 gap-y-3">
|
|
24
|
+
<div className="flex min-w-0 items-end gap-3">
|
|
25
|
+
<Circle name={forum.title} size={72} className="ring-4 ring-card" />
|
|
26
|
+
|
|
27
|
+
<div className="min-w-0">
|
|
28
|
+
<h1 className="text-xl leading-tight font-bold tracking-tight text-balance">
|
|
29
|
+
{forum.title}
|
|
30
|
+
</h1>
|
|
31
|
+
{forum.type !== 'link' && (
|
|
32
|
+
<p className={`mt-0.5 text-xs text-muted-foreground ${NUMERIC}`}>
|
|
33
|
+
{count(forum.threadCount)} {plural(forum.threadCount, 'thread', 'threads')} ·{' '}
|
|
34
|
+
{count(forum.postCount)} {plural(forum.postCount, 'post', 'posts')}
|
|
35
|
+
</p>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
{markReadAction !== null && (
|
|
41
|
+
<form action={markReadAction} method="post" className="shrink-0">
|
|
42
|
+
<button type="submit" className={PILL}>
|
|
43
|
+
Mark read
|
|
44
|
+
</button>
|
|
45
|
+
</form>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{forum.description !== null && (
|
|
50
|
+
<p className="mt-3 text-sm text-muted-foreground">{forum.description}</p>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
</section>
|
|
54
|
+
|
|
55
|
+
{newThreadHref !== null && (
|
|
56
|
+
<div className="flex items-center gap-2.5 rounded-lg border border-border bg-card px-4 py-3 shadow-elevation">
|
|
57
|
+
<Circle name={forum.title} size={40} />
|
|
58
|
+
<a
|
|
59
|
+
href={newThreadHref}
|
|
60
|
+
className="min-w-0 flex-1 truncate rounded-full bg-secondary px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
|
|
61
|
+
>
|
|
62
|
+
Start a thread in {forum.title}…
|
|
63
|
+
</a>
|
|
64
|
+
<a href={newThreadHref} className={`hidden sm:inline-flex ${PILL_PRIMARY}`}>
|
|
65
|
+
New thread
|
|
66
|
+
</a>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{regions.tools !== undefined && (
|
|
71
|
+
<div className="flex flex-col gap-3 empty:hidden">{regions.tools}</div>
|
|
72
|
+
)}
|
|
73
|
+
|
|
74
|
+
{regions.announcements !== undefined && (
|
|
75
|
+
<div className="flex flex-col gap-4 empty:hidden">{regions.announcements}</div>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{regions.subforums}
|
|
79
|
+
|
|
80
|
+
{isEmptyRegion(regions.threads) ? (
|
|
81
|
+
<div className="rounded-lg border border-border bg-card px-4 py-10 text-center shadow-elevation">
|
|
82
|
+
<p className="text-base font-semibold">No threads here yet</p>
|
|
83
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
84
|
+
{newThreadHref === null
|
|
85
|
+
? 'Nothing has been posted in this forum.'
|
|
86
|
+
: 'Nothing has been posted in this forum. Yours would be the first.'}
|
|
87
|
+
</p>
|
|
88
|
+
{newThreadHref !== null && (
|
|
89
|
+
<a href={newThreadHref} className={`mt-4 ${PILL_PRIMARY}`}>
|
|
90
|
+
Start the first thread
|
|
91
|
+
</a>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
) : (
|
|
95
|
+
<ul className="flex flex-col gap-3">{regions.threads}</ul>
|
|
96
|
+
)}
|
|
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 rounded-lg border border-border bg-card px-4 py-3 shadow-elevation empty:hidden">
|
|
102
|
+
{regions.afterContent}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ForumJumpModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { PAGE, PILL } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function ForumJump({ action, field, forums, submitLabel, label }: ForumJumpModel) {
|
|
6
|
+
if (forums.length === 0) return null
|
|
7
|
+
|
|
8
|
+
const id = `forum-jump-${field}`
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="bg-background">
|
|
12
|
+
<form
|
|
13
|
+
method="get"
|
|
14
|
+
action={action}
|
|
15
|
+
className={`${PAGE} flex flex-wrap items-center gap-2 pb-4 sm:justify-end`}
|
|
16
|
+
>
|
|
17
|
+
<label htmlFor={id} className="text-xs font-semibold text-muted-foreground">
|
|
18
|
+
{label}
|
|
19
|
+
</label>
|
|
20
|
+
|
|
21
|
+
<select
|
|
22
|
+
id={id}
|
|
23
|
+
name={field}
|
|
24
|
+
defaultValue={forums.find((forum) => forum.isSelected)?.value ?? ''}
|
|
25
|
+
className="h-9 w-auto min-w-48 flex-1 rounded-full border border-input bg-card px-3 text-xs text-foreground sm:flex-none"
|
|
26
|
+
>
|
|
27
|
+
{forums.map((forum) => {
|
|
28
|
+
const indented = `${' '.repeat(forum.depth)}${forum.label}`
|
|
29
|
+
return (
|
|
30
|
+
<option
|
|
31
|
+
key={forum.value}
|
|
32
|
+
value={forum.value}
|
|
33
|
+
disabled={forum.isCategory}
|
|
34
|
+
label={indented}
|
|
35
|
+
>
|
|
36
|
+
{indented}
|
|
37
|
+
</option>
|
|
38
|
+
)
|
|
39
|
+
})}
|
|
40
|
+
</select>
|
|
41
|
+
|
|
42
|
+
<button type="submit" className={PILL}>
|
|
43
|
+
{submitLabel}
|
|
44
|
+
</button>
|
|
45
|
+
</form>
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ForumRowSlotModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { Circle, MUTED_LINK, NUMERIC, Stamp, UserRef, count, plural } 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="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent"
|
|
12
|
+
>
|
|
13
|
+
<span className="relative shrink-0">
|
|
14
|
+
<Circle name={forum.title} size={40} />
|
|
15
|
+
{forum.isUnread && (
|
|
16
|
+
<span
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
className="absolute -top-0.5 -right-0.5 size-3 rounded-full bg-forum-unread ring-2 ring-card"
|
|
19
|
+
/>
|
|
20
|
+
)}
|
|
21
|
+
</span>
|
|
22
|
+
|
|
23
|
+
<div className="min-w-0 flex-1">
|
|
24
|
+
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
|
25
|
+
<a
|
|
26
|
+
href={forum.href}
|
|
27
|
+
className={`text-[0.9375rem] hover:underline ${forum.isUnread ? 'font-bold text-foreground' : 'font-semibold text-foreground'}`}
|
|
28
|
+
>
|
|
29
|
+
{forum.title}
|
|
30
|
+
</a>
|
|
31
|
+
{forum.isUnread && <span className="sr-only"> (new posts)</span>}
|
|
32
|
+
|
|
33
|
+
{!isLink && (
|
|
34
|
+
<span className={`shrink-0 text-xs text-muted-foreground ${NUMERIC}`}>
|
|
35
|
+
{count(forum.threadCount)} {plural(forum.threadCount, 'thread', 'threads')} ·{' '}
|
|
36
|
+
{count(forum.postCount)} {plural(forum.postCount, 'post', 'posts')}
|
|
37
|
+
</span>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
{forum.description !== null && (
|
|
42
|
+
<p className="mt-0.5 text-sm text-muted-foreground">{forum.description}</p>
|
|
43
|
+
)}
|
|
44
|
+
|
|
45
|
+
{forum.subforums.length > 0 && (
|
|
46
|
+
<p className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
47
|
+
{forum.subforums.map((sub) => (
|
|
48
|
+
<a
|
|
49
|
+
key={sub.href}
|
|
50
|
+
href={sub.href}
|
|
51
|
+
className="rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
52
|
+
>
|
|
53
|
+
{sub.label}
|
|
54
|
+
</a>
|
|
55
|
+
))}
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
{!isLink && (
|
|
60
|
+
<p className="mt-1.5 truncate text-xs text-muted-foreground">
|
|
61
|
+
{forum.lastPost === null ? (
|
|
62
|
+
<span className="text-forum-read">No posts yet</span>
|
|
63
|
+
) : (
|
|
64
|
+
<>
|
|
65
|
+
<a href={forum.lastPost.href} className={`font-semibold ${MUTED_LINK}`}>
|
|
66
|
+
{forum.lastPost.threadTitle}
|
|
67
|
+
</a>
|
|
68
|
+
{' — '}
|
|
69
|
+
<UserRef user={forum.lastPost.author} className="text-xs font-medium" />
|
|
70
|
+
{' · '}
|
|
71
|
+
<Stamp at={forum.lastPost.at} />
|
|
72
|
+
</>
|
|
73
|
+
)}
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</li>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { HeaderModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { PAGE } from '../shared'
|
|
4
|
+
|
|
5
|
+
function BoardMark({ boardTitle, logo }: Pick<HeaderModel, 'boardTitle' | 'logo'>) {
|
|
6
|
+
if (logo === undefined) {
|
|
7
|
+
const initial = (Array.from(boardTitle.trim())[0] ?? '?').toUpperCase()
|
|
8
|
+
return (
|
|
9
|
+
<span
|
|
10
|
+
aria-hidden="true"
|
|
11
|
+
className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground"
|
|
12
|
+
>
|
|
13
|
+
{initial}
|
|
14
|
+
</span>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const image = (
|
|
19
|
+
<img
|
|
20
|
+
src={logo.src}
|
|
21
|
+
alt=""
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
className="size-10 shrink-0 rounded-full object-cover"
|
|
24
|
+
decoding="async"
|
|
25
|
+
/>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if (logo.darkSrc === null) return image
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<picture>
|
|
32
|
+
<source media="(prefers-color-scheme: dark)" srcSet={logo.darkSrc} />
|
|
33
|
+
{image}
|
|
34
|
+
</picture>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Header({ boardTitle, homeHref, navigation, logo, children }: HeaderModel) {
|
|
39
|
+
return (
|
|
40
|
+
<header className="sticky top-0 z-30 border-b border-border bg-card shadow-elevation">
|
|
41
|
+
<div className={`${PAGE} flex h-14 items-center justify-between gap-3`}>
|
|
42
|
+
<a
|
|
43
|
+
href={homeHref}
|
|
44
|
+
className="flex min-w-0 shrink-0 items-center gap-2 rounded-full transition-opacity hover:opacity-80"
|
|
45
|
+
>
|
|
46
|
+
<BoardMark boardTitle={boardTitle} logo={logo} />
|
|
47
|
+
<span className="hidden truncate text-lg font-bold tracking-tight text-foreground sm:inline">
|
|
48
|
+
{boardTitle}
|
|
49
|
+
</span>
|
|
50
|
+
</a>
|
|
51
|
+
|
|
52
|
+
{navigation.length > 0 && (
|
|
53
|
+
<nav aria-label="Board sections" className="hidden min-w-0 flex-1 justify-center lg:flex">
|
|
54
|
+
<ul className="flex items-center gap-1">
|
|
55
|
+
{navigation.map((item) => (
|
|
56
|
+
<li key={item.href}>
|
|
57
|
+
<a
|
|
58
|
+
href={item.href}
|
|
59
|
+
className="inline-flex h-11 items-center rounded-lg px-6 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
60
|
+
>
|
|
61
|
+
{item.label}
|
|
62
|
+
</a>
|
|
63
|
+
</li>
|
|
64
|
+
))}
|
|
65
|
+
</ul>
|
|
66
|
+
</nav>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
<div className="flex shrink-0 items-center justify-end">{children}</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{navigation.length > 0 && (
|
|
73
|
+
<nav aria-label="Board sections" className="border-t border-border lg:hidden">
|
|
74
|
+
<ul className={`${PAGE} flex items-center gap-1 overflow-x-auto py-1`}>
|
|
75
|
+
{navigation.map((item) => (
|
|
76
|
+
<li key={item.href} className="shrink-0">
|
|
77
|
+
<a
|
|
78
|
+
href={item.href}
|
|
79
|
+
className="inline-flex h-9 items-center rounded-full px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
80
|
+
>
|
|
81
|
+
{item.label}
|
|
82
|
+
</a>
|
|
83
|
+
</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
</nav>
|
|
87
|
+
)}
|
|
88
|
+
</header>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { LatestPostsModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { Circle, MUTED_LINK, NUMERIC, Rail, Stamp, UserRef } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function LatestPosts({ posts, capturedAt }: LatestPostsModel) {
|
|
6
|
+
return (
|
|
7
|
+
<Rail
|
|
8
|
+
title="Latest posts"
|
|
9
|
+
titleId="latest-posts-heading"
|
|
10
|
+
action={
|
|
11
|
+
<span className={`text-xs text-muted-foreground ${NUMERIC}`}>
|
|
12
|
+
<Stamp at={capturedAt} />
|
|
13
|
+
</span>
|
|
14
|
+
}
|
|
15
|
+
>
|
|
16
|
+
{posts.length === 0 ? (
|
|
17
|
+
<p className="px-3 pt-1 pb-3 text-xs text-muted-foreground">
|
|
18
|
+
The newest posts you can see will appear here.
|
|
19
|
+
</p>
|
|
20
|
+
) : (
|
|
21
|
+
<ul className="px-1 pt-1 pb-1">
|
|
22
|
+
{posts.map((post) => (
|
|
23
|
+
<li key={post.href} className="rounded-lg px-2 py-1.5 transition-colors hover:bg-accent">
|
|
24
|
+
<div className="flex items-start gap-2.5">
|
|
25
|
+
<Circle name={post.author.username} size={32} className="mt-0.5" />
|
|
26
|
+
|
|
27
|
+
<div className="min-w-0 flex-1">
|
|
28
|
+
<a
|
|
29
|
+
href={post.href}
|
|
30
|
+
className="line-clamp-2 text-sm font-semibold text-foreground hover:underline"
|
|
31
|
+
>
|
|
32
|
+
{post.threadTitle}
|
|
33
|
+
</a>
|
|
34
|
+
|
|
35
|
+
{post.excerpt !== '' && (
|
|
36
|
+
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
|
37
|
+
{post.excerpt}
|
|
38
|
+
</p>
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
42
|
+
<UserRef user={post.author} className="text-xs font-medium" />
|
|
43
|
+
{' in '}
|
|
44
|
+
<a href={post.forum.href} className={MUTED_LINK}>
|
|
45
|
+
{post.forum.label}
|
|
46
|
+
</a>
|
|
47
|
+
{' · '}
|
|
48
|
+
<Stamp at={post.postedAt} />
|
|
49
|
+
</p>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
)}
|
|
56
|
+
</Rail>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { LatestThreadsModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { Circle, MUTED_LINK, NUMERIC, Rail, Stamp, UserRef, count, plural } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function LatestThreads({ threads, capturedAt }: LatestThreadsModel) {
|
|
6
|
+
return (
|
|
7
|
+
<Rail
|
|
8
|
+
title="Latest threads"
|
|
9
|
+
titleId="latest-threads-heading"
|
|
10
|
+
action={
|
|
11
|
+
<span className={`text-xs text-muted-foreground ${NUMERIC}`}>
|
|
12
|
+
<Stamp at={capturedAt} />
|
|
13
|
+
</span>
|
|
14
|
+
}
|
|
15
|
+
>
|
|
16
|
+
{threads.length === 0 ? (
|
|
17
|
+
<p className="px-3 pt-1 pb-3 text-xs text-muted-foreground">
|
|
18
|
+
The newest threads you can see will appear here.
|
|
19
|
+
</p>
|
|
20
|
+
) : (
|
|
21
|
+
<ul className="px-1 pt-1 pb-1">
|
|
22
|
+
{threads.map((thread) => (
|
|
23
|
+
<li key={thread.href} className="rounded-lg px-2 py-1.5 transition-colors hover:bg-accent">
|
|
24
|
+
<div className="flex items-start gap-2.5">
|
|
25
|
+
<Circle name={thread.author.username} size={32} className="mt-0.5" />
|
|
26
|
+
|
|
27
|
+
<div className="min-w-0 flex-1">
|
|
28
|
+
<a
|
|
29
|
+
href={thread.href}
|
|
30
|
+
className="line-clamp-2 text-sm font-semibold text-foreground hover:underline"
|
|
31
|
+
>
|
|
32
|
+
{thread.title}
|
|
33
|
+
</a>
|
|
34
|
+
|
|
35
|
+
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
36
|
+
<UserRef user={thread.author} className="text-xs font-medium" />
|
|
37
|
+
{' in '}
|
|
38
|
+
<a href={thread.forum.href} className={MUTED_LINK}>
|
|
39
|
+
{thread.forum.label}
|
|
40
|
+
</a>
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
<p className={`mt-0.5 truncate text-xs text-muted-foreground ${NUMERIC}`}>
|
|
44
|
+
{count(thread.replyCount)} {plural(thread.replyCount, 'reply', 'replies')} ·{' '}
|
|
45
|
+
<Stamp at={thread.startedAt} />
|
|
46
|
+
</p>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
)}
|
|
53
|
+
</Rail>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { cn } from '@meith/ui'
|
|
2
|
+
import type { MemberProfileModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
import { Circle, FEED, NUMERIC, PAGE, PILL, PILL_PRIMARY, Stamp, count } 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} py-4 sm:py-6`}>
|
|
20
|
+
<div className={`${FEED} flex flex-col gap-4`}>
|
|
21
|
+
<section className="overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
|
|
22
|
+
<div className="h-32 bg-primary/12 sm:h-40" />
|
|
23
|
+
|
|
24
|
+
<div className="flex flex-col items-center gap-3 px-4 pb-4 sm:flex-row sm:items-end sm:justify-between">
|
|
25
|
+
<div className="-mt-12 flex flex-col items-center gap-3 sm:flex-row sm:items-end">
|
|
26
|
+
<Circle
|
|
27
|
+
src={avatarUrl}
|
|
28
|
+
name={user.username}
|
|
29
|
+
size={112}
|
|
30
|
+
className="ring-4 ring-card"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
<div className="min-w-0 pb-1 text-center sm:text-left">
|
|
34
|
+
<h1
|
|
35
|
+
className={cn(
|
|
36
|
+
'text-2xl leading-tight font-bold tracking-tight break-words',
|
|
37
|
+
user.nameClass,
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{user.username}
|
|
41
|
+
</h1>
|
|
42
|
+
{title !== null && <p className="text-sm text-muted-foreground">{title}</p>}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{actions.length > 0 && (
|
|
47
|
+
<nav
|
|
48
|
+
aria-label="Member actions"
|
|
49
|
+
className="flex shrink-0 flex-wrap items-center justify-center gap-2"
|
|
50
|
+
>
|
|
51
|
+
{actions.map((action, index) => (
|
|
52
|
+
<a
|
|
53
|
+
key={action.href}
|
|
54
|
+
href={action.href}
|
|
55
|
+
className={index === 0 ? PILL_PRIMARY : PILL}
|
|
56
|
+
>
|
|
57
|
+
{action.label}
|
|
58
|
+
</a>
|
|
59
|
+
))}
|
|
60
|
+
</nav>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<dl className="grid grid-cols-3 gap-2 border-t border-border px-4 py-3 text-center">
|
|
65
|
+
<div>
|
|
66
|
+
<dd className={`text-lg font-bold text-foreground ${NUMERIC}`}>
|
|
67
|
+
{count(postCount)}
|
|
68
|
+
</dd>
|
|
69
|
+
<dt className="text-xs text-muted-foreground">Posts</dt>
|
|
70
|
+
</div>
|
|
71
|
+
<div>
|
|
72
|
+
<dd className="text-sm font-semibold text-foreground">
|
|
73
|
+
<Stamp at={joinedAt} />
|
|
74
|
+
</dd>
|
|
75
|
+
<dt className="text-xs text-muted-foreground">Joined</dt>
|
|
76
|
+
</div>
|
|
77
|
+
<div>
|
|
78
|
+
<dd className="text-sm font-semibold text-foreground">
|
|
79
|
+
{lastVisitAt === null ? 'Never' : <Stamp at={lastVisitAt} />}
|
|
80
|
+
</dd>
|
|
81
|
+
<dt className="text-xs text-muted-foreground">Last visit</dt>
|
|
82
|
+
</div>
|
|
83
|
+
</dl>
|
|
84
|
+
</section>
|
|
85
|
+
|
|
86
|
+
{fields.length > 0 && (
|
|
87
|
+
<section
|
|
88
|
+
aria-labelledby="profile-fields-heading"
|
|
89
|
+
className="rounded-lg border border-border bg-card text-card-foreground shadow-elevation"
|
|
90
|
+
>
|
|
91
|
+
<h2
|
|
92
|
+
id="profile-fields-heading"
|
|
93
|
+
className="px-4 pt-3 pb-1 text-[1.0625rem] font-bold tracking-tight"
|
|
94
|
+
>
|
|
95
|
+
About
|
|
96
|
+
</h2>
|
|
97
|
+
<dl className="grid gap-x-6 gap-y-3 px-4 pt-2 pb-4 sm:grid-cols-2">
|
|
98
|
+
{fields.map((field) => (
|
|
99
|
+
<div key={field.label}>
|
|
100
|
+
<dt className="text-xs text-muted-foreground">{field.label}</dt>
|
|
101
|
+
<dd className="text-sm break-words">{field.value}</dd>
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</dl>
|
|
105
|
+
</section>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{signatureHtml !== null && (
|
|
109
|
+
<section
|
|
110
|
+
aria-labelledby="profile-signature-heading"
|
|
111
|
+
className="rounded-lg border border-border bg-card text-card-foreground shadow-elevation"
|
|
112
|
+
>
|
|
113
|
+
<h2
|
|
114
|
+
id="profile-signature-heading"
|
|
115
|
+
className="px-4 pt-3 pb-1 text-[1.0625rem] font-bold tracking-tight"
|
|
116
|
+
>
|
|
117
|
+
Signature
|
|
118
|
+
</h2>
|
|
119
|
+
<div
|
|
120
|
+
className="prose-md px-4 pt-2 pb-4 text-sm text-muted-foreground"
|
|
121
|
+
dangerouslySetInnerHTML={{ __html: signatureHtml }}
|
|
122
|
+
/>
|
|
123
|
+
</section>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
{regions?.plugins}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { NavigationModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { 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="bg-background">
|
|
10
|
+
<ol
|
|
11
|
+
className={`${PAGE} flex items-center gap-1 overflow-x-auto pt-3 text-sm 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">
|
|
17
|
+
{index > 0 && (
|
|
18
|
+
<span aria-hidden="true" className="select-none opacity-50">
|
|
19
|
+
›
|
|
20
|
+
</span>
|
|
21
|
+
)}
|
|
22
|
+
{isLast ? (
|
|
23
|
+
<span
|
|
24
|
+
aria-current="page"
|
|
25
|
+
className="max-w-[24ch] truncate font-semibold text-foreground sm:max-w-none"
|
|
26
|
+
>
|
|
27
|
+
{item.label}
|
|
28
|
+
</span>
|
|
29
|
+
) : (
|
|
30
|
+
<a
|
|
31
|
+
href={item.href}
|
|
32
|
+
className="rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
|
|
33
|
+
>
|
|
34
|
+
{item.label}
|
|
35
|
+
</a>
|
|
36
|
+
)}
|
|
37
|
+
</li>
|
|
38
|
+
)
|
|
39
|
+
})}
|
|
40
|
+
</ol>
|
|
41
|
+
</nav>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cn } from '@meith/ui'
|
|
2
|
+
import type { NoticeModel } from '@meith/theme-kit'
|
|
3
|
+
|
|
4
|
+
const KIND = {
|
|
5
|
+
info: {
|
|
6
|
+
label: 'Notice',
|
|
7
|
+
className: 'border-primary/30 bg-primary/8 text-foreground',
|
|
8
|
+
mark: 'bg-primary text-primary-foreground',
|
|
9
|
+
},
|
|
10
|
+
success: {
|
|
11
|
+
label: 'Done',
|
|
12
|
+
className: 'border-moderation-approved/30 bg-moderation-approved/8 text-foreground',
|
|
13
|
+
mark: 'bg-moderation-approved text-card',
|
|
14
|
+
},
|
|
15
|
+
warning: {
|
|
16
|
+
label: 'Warning',
|
|
17
|
+
className: 'border-thread-pinned/30 bg-thread-pinned/8 text-foreground',
|
|
18
|
+
mark: 'bg-thread-pinned text-card',
|
|
19
|
+
},
|
|
20
|
+
error: {
|
|
21
|
+
label: 'Error',
|
|
22
|
+
className: 'border-destructive/30 bg-destructive/8 text-foreground',
|
|
23
|
+
mark: 'bg-destructive text-destructive-foreground',
|
|
24
|
+
},
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
export function Notice({ kind, message, dismissHref }: NoticeModel) {
|
|
28
|
+
const tone = KIND[kind]
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
role={kind === 'error' ? 'alert' : 'status'}
|
|
33
|
+
className={cn(
|
|
34
|
+
'flex items-center gap-3 rounded-lg border px-3 py-2.5 text-sm shadow-elevation',
|
|
35
|
+
tone.className,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<span
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
className={cn(
|
|
41
|
+
'inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
|
|
42
|
+
tone.mark,
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{kind === 'error' || kind === 'warning' ? '!' : 'i'}
|
|
46
|
+
</span>
|
|
47
|
+
|
|
48
|
+
<p className="min-w-0 flex-1">
|
|
49
|
+
<span className="font-semibold">{tone.label}:</span> {message}
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
{dismissHref !== null && (
|
|
53
|
+
<a
|
|
54
|
+
href={dismissHref}
|
|
55
|
+
className="shrink-0 rounded-full px-2 py-1 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
56
|
+
>
|
|
57
|
+
Dismiss
|
|
58
|
+
</a>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|