@meith/theme-raidframe 0.2.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 +133 -0
- package/src/slots/announcement.tsx +40 -0
- package/src/slots/auth-page.tsx +53 -0
- package/src/slots/board-index.tsx +35 -0
- package/src/slots/board-stats.tsx +52 -0
- package/src/slots/category-block.tsx +37 -0
- package/src/slots/discovery-view.tsx +122 -0
- package/src/slots/error-notice.tsx +26 -0
- package/src/slots/footer.tsx +34 -0
- package/src/slots/forum-display.tsx +81 -0
- package/src/slots/forum-jump.tsx +45 -0
- package/src/slots/forum-row.tsx +66 -0
- package/src/slots/header.tsx +67 -0
- package/src/slots/latest-posts.tsx +42 -0
- package/src/slots/latest-threads.tsx +42 -0
- package/src/slots/member-profile.tsx +102 -0
- package/src/slots/navigation.tsx +31 -0
- package/src/slots/notice.tsx +32 -0
- package/src/slots/pagination.tsx +48 -0
- package/src/slots/panel-nav.tsx +110 -0
- package/src/slots/panel-page.tsx +72 -0
- package/src/slots/panel-shell.tsx +33 -0
- package/src/slots/post-actions.tsx +29 -0
- package/src/slots/post-bit.tsx +225 -0
- package/src/slots/post-form.tsx +44 -0
- package/src/slots/redirect-notice.tsx +26 -0
- package/src/slots/search-form.tsx +102 -0
- package/src/slots/search-results.tsx +108 -0
- package/src/slots/shell.tsx +40 -0
- package/src/slots/subforum-list.tsx +26 -0
- package/src/slots/thread-row.tsx +74 -0
- package/src/slots/thread-view.tsx +75 -0
- package/src/slots/user-panel.tsx +118 -0
- package/src/slots/who-is-online.tsx +64 -0
- package/src/theme.ts +84 -0
- package/src/tokens.ts +104 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ErrorNoticeModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { BUTTON_PRIMARY, Frame, HEADING, MICRO, NUMERIC } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function ErrorNotice({ status, title, message, homeHref, requestId }: ErrorNoticeModel) {
|
|
6
|
+
return (
|
|
7
|
+
<Frame className="w-full max-w-lg">
|
|
8
|
+
<div className="px-5 py-5">
|
|
9
|
+
<p className={`${MICRO} ${NUMERIC} text-destructive`}>error {status}</p>
|
|
10
|
+
<h1 className={`${HEADING} mt-1 text-2xl text-foreground`}>{title}</h1>
|
|
11
|
+
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
|
|
12
|
+
|
|
13
|
+
<a href={homeHref} className={`${BUTTON_PRIMARY} mt-5`}>
|
|
14
|
+
forum home
|
|
15
|
+
</a>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
{requestId !== null && (
|
|
19
|
+
<p className={`${MICRO} border-t border-border px-5 py-2 normal-case`}>
|
|
20
|
+
<span className="uppercase">request id</span>{' '}
|
|
21
|
+
<code className={`${NUMERIC} text-foreground select-all`}>{requestId}</code>
|
|
22
|
+
</p>
|
|
23
|
+
)}
|
|
24
|
+
</Frame>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { FooterModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { RULE } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function Footer({ boardTitle, links, timezoneLabel, poweredBy }: FooterModel) {
|
|
6
|
+
return (
|
|
7
|
+
<footer className="mt-auto">
|
|
8
|
+
<div className={RULE} aria-hidden="true" />
|
|
9
|
+
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 border-t border-border bg-surface px-4 py-3 font-mono text-[0.6875rem] tracking-[0.12em] text-muted-foreground uppercase">
|
|
10
|
+
<span className="inline-flex items-center gap-2 text-foreground">
|
|
11
|
+
<span aria-hidden="true" className="size-2 rotate-45 border border-primary" />
|
|
12
|
+
{boardTitle}
|
|
13
|
+
</span>
|
|
14
|
+
|
|
15
|
+
<nav aria-label="Board links" className="flex flex-wrap gap-x-4 gap-y-1">
|
|
16
|
+
{links.map((link) => (
|
|
17
|
+
<a key={link.href} href={link.href} className="hover:text-primary">
|
|
18
|
+
{link.label}
|
|
19
|
+
</a>
|
|
20
|
+
))}
|
|
21
|
+
</nav>
|
|
22
|
+
|
|
23
|
+
<span className="flex flex-wrap gap-x-4 gap-y-1">
|
|
24
|
+
<span>all times {timezoneLabel}</span>
|
|
25
|
+
{poweredBy && (
|
|
26
|
+
<a href={poweredBy.href} className="hover:text-primary">
|
|
27
|
+
{poweredBy.label}
|
|
28
|
+
</a>
|
|
29
|
+
)}
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
</footer>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ForumDisplayModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { BUTTON, BUTTON_PRIMARY, Frame, HEADING, MICRO, NUMERIC, RULE } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function ForumDisplay({ forum, newThreadHref, markReadAction, regions }: ForumDisplayModel) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="flex flex-col gap-4 p-4">
|
|
8
|
+
<div className="border border-border bg-card shadow-elevation">
|
|
9
|
+
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
|
10
|
+
<div className="min-w-0">
|
|
11
|
+
<p className={MICRO}>forum</p>
|
|
12
|
+
<h1 className={`${HEADING} text-xl text-foreground`}>{forum.title}</h1>
|
|
13
|
+
{forum.description !== null && (
|
|
14
|
+
<p className="mt-1 text-xs text-muted-foreground">{forum.description}</p>
|
|
15
|
+
)}
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
19
|
+
<span className={`${MICRO} ${NUMERIC} normal-case`}>
|
|
20
|
+
<span className="text-foreground">{forum.threadCount}</span> threads
|
|
21
|
+
<span className="text-border">{' | '}</span>
|
|
22
|
+
<span className="text-foreground">{forum.postCount}</span> posts
|
|
23
|
+
</span>
|
|
24
|
+
{newThreadHref !== null && (
|
|
25
|
+
<a href={newThreadHref} className={BUTTON_PRIMARY}>
|
|
26
|
+
new thread
|
|
27
|
+
</a>
|
|
28
|
+
)}
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div className={RULE} aria-hidden="true" />
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
{regions.announcements !== undefined && (
|
|
35
|
+
<div className="flex flex-col gap-4 empty:hidden">{regions.announcements}</div>
|
|
36
|
+
)}
|
|
37
|
+
|
|
38
|
+
{regions.subforums}
|
|
39
|
+
|
|
40
|
+
{regions.tools !== undefined && (
|
|
41
|
+
<div className="flex flex-col gap-2 empty:hidden">{regions.tools}</div>
|
|
42
|
+
)}
|
|
43
|
+
|
|
44
|
+
<Frame>
|
|
45
|
+
<table className="w-full border-collapse text-sm">
|
|
46
|
+
<thead>
|
|
47
|
+
<tr className={`${MICRO} border-b border-border bg-surface text-left`}>
|
|
48
|
+
<th scope="col" className="px-3 py-2 font-semibold">
|
|
49
|
+
Thread
|
|
50
|
+
</th>
|
|
51
|
+
<th scope="col" className="w-16 px-2 py-2 text-right font-semibold">
|
|
52
|
+
Replies
|
|
53
|
+
</th>
|
|
54
|
+
<th scope="col" className="w-16 px-2 py-2 text-right font-semibold">
|
|
55
|
+
Views
|
|
56
|
+
</th>
|
|
57
|
+
<th scope="col" className="hidden w-52 px-3 py-2 font-semibold sm:table-cell">
|
|
58
|
+
Last post
|
|
59
|
+
</th>
|
|
60
|
+
</tr>
|
|
61
|
+
</thead>
|
|
62
|
+
<tbody>{regions.threads}</tbody>
|
|
63
|
+
</table>
|
|
64
|
+
</Frame>
|
|
65
|
+
|
|
66
|
+
{regions.pagination}
|
|
67
|
+
|
|
68
|
+
{regions.afterContent !== undefined && (
|
|
69
|
+
<div className="flex flex-col gap-2 empty:hidden">{regions.afterContent}</div>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
{markReadAction !== null && (
|
|
73
|
+
<form action={markReadAction} method="post">
|
|
74
|
+
<button type="submit" className={BUTTON}>
|
|
75
|
+
mark this forum read
|
|
76
|
+
</button>
|
|
77
|
+
</form>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ForumJumpModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { BUTTON, MICRO } 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="border-t border-border bg-card/40">
|
|
12
|
+
<form
|
|
13
|
+
method="get"
|
|
14
|
+
action={action}
|
|
15
|
+
className="mx-auto flex w-full max-w-6xl flex-wrap items-center gap-2 px-4 py-3 sm:justify-end"
|
|
16
|
+
>
|
|
17
|
+
<label htmlFor={id} className={MICRO}>
|
|
18
|
+
{label}
|
|
19
|
+
</label>
|
|
20
|
+
|
|
21
|
+
<select
|
|
22
|
+
id={id}
|
|
23
|
+
name={field}
|
|
24
|
+
defaultValue={forums.find((forum) => forum.isSelected)?.value ?? ''}
|
|
25
|
+
className="h-8 w-auto min-w-48 flex-1 border border-input bg-surface px-2 font-mono text-xs text-foreground sm:flex-none"
|
|
26
|
+
>
|
|
27
|
+
{forums.map((forum) => (
|
|
28
|
+
<option
|
|
29
|
+
key={forum.value}
|
|
30
|
+
value={forum.value}
|
|
31
|
+
disabled={forum.isCategory}
|
|
32
|
+
label={`${' '.repeat(forum.depth)}${forum.label}`}
|
|
33
|
+
>
|
|
34
|
+
{`${' '.repeat(forum.depth)}${forum.label}`}
|
|
35
|
+
</option>
|
|
36
|
+
))}
|
|
37
|
+
</select>
|
|
38
|
+
|
|
39
|
+
<button type="submit" className={BUTTON}>
|
|
40
|
+
{submitLabel}
|
|
41
|
+
</button>
|
|
42
|
+
</form>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ForumRowSlotModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { HEADING, MICRO, NUMERIC, ReadPip, Stamp, UserRef } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function ForumRow({ forum }: ForumRowSlotModel) {
|
|
6
|
+
const isLink = forum.type === 'link'
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<tr className="border-t border-border align-top hover:bg-secondary/50">
|
|
10
|
+
<td className="px-3 py-2.5">
|
|
11
|
+
<div className="flex items-baseline gap-2">
|
|
12
|
+
<ReadPip unread={forum.isUnread} className="mt-1.5" />
|
|
13
|
+
<div className="min-w-0">
|
|
14
|
+
<a href={forum.href} className={`${HEADING} text-sm text-foreground hover:text-primary`}>
|
|
15
|
+
{forum.title}
|
|
16
|
+
</a>
|
|
17
|
+
{forum.isUnread && <span className="sr-only">(new posts)</span>}
|
|
18
|
+
{isLink && <span className={`${MICRO} ml-2`}>link</span>}
|
|
19
|
+
|
|
20
|
+
{forum.description !== null && (
|
|
21
|
+
<p className="mt-0.5 text-xs text-muted-foreground">{forum.description}</p>
|
|
22
|
+
)}
|
|
23
|
+
|
|
24
|
+
{forum.subforums.length > 0 && (
|
|
25
|
+
<p className="mt-1.5 flex flex-wrap gap-1.5">
|
|
26
|
+
{forum.subforums.map((sub) => (
|
|
27
|
+
<a
|
|
28
|
+
key={sub.href}
|
|
29
|
+
href={sub.href}
|
|
30
|
+
className="border border-border bg-surface px-1.5 py-0.5 font-mono text-[0.625rem] tracking-[0.1em] text-muted-foreground uppercase hover:border-primary/60 hover:text-primary"
|
|
31
|
+
>
|
|
32
|
+
{sub.label}
|
|
33
|
+
</a>
|
|
34
|
+
))}
|
|
35
|
+
</p>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</td>
|
|
40
|
+
|
|
41
|
+
<td className={`w-16 px-2 py-2.5 text-right text-xs ${NUMERIC} text-foreground`}>
|
|
42
|
+
{isLink ? <span className="text-muted-foreground">—</span> : forum.threadCount}
|
|
43
|
+
</td>
|
|
44
|
+
<td className={`w-16 px-2 py-2.5 text-right text-xs ${NUMERIC} text-foreground`}>
|
|
45
|
+
{isLink ? <span className="text-muted-foreground">—</span> : forum.postCount}
|
|
46
|
+
</td>
|
|
47
|
+
|
|
48
|
+
<td className="hidden w-60 px-3 py-2.5 text-xs text-muted-foreground sm:table-cell">
|
|
49
|
+
{isLink || forum.lastPost === null ? (
|
|
50
|
+
<span className={MICRO}>{isLink ? '' : 'no posts yet'}</span>
|
|
51
|
+
) : (
|
|
52
|
+
<>
|
|
53
|
+
<a href={forum.lastPost.href} className="block truncate text-foreground hover:text-primary">
|
|
54
|
+
{forum.lastPost.threadTitle}
|
|
55
|
+
</a>
|
|
56
|
+
<span className={`${MICRO} mt-0.5 block normal-case`}>
|
|
57
|
+
<UserRef user={forum.lastPost.author} className="hover:text-primary" />
|
|
58
|
+
{' · '}
|
|
59
|
+
<Stamp at={forum.lastPost.at} />
|
|
60
|
+
</span>
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
</td>
|
|
64
|
+
</tr>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { HeaderModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { RULE } from '../shared'
|
|
4
|
+
|
|
5
|
+
function BoardMark({ boardTitle, logo }: Pick<HeaderModel, 'boardTitle' | 'logo'>) {
|
|
6
|
+
if (logo === undefined) {
|
|
7
|
+
return (
|
|
8
|
+
<span className="font-heading text-xl leading-none font-bold tracking-[0.16em] text-foreground uppercase">
|
|
9
|
+
{boardTitle}
|
|
10
|
+
</span>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const image = (
|
|
15
|
+
<img
|
|
16
|
+
src={logo.src}
|
|
17
|
+
alt={logo.alt}
|
|
18
|
+
className="h-8 w-auto max-w-48 object-contain"
|
|
19
|
+
decoding="async"
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if (logo.darkSrc === null) return image
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<picture>
|
|
27
|
+
<source media="(prefers-color-scheme: dark)" srcSet={logo.darkSrc} />
|
|
28
|
+
{image}
|
|
29
|
+
</picture>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Header({ boardTitle, homeHref, navigation, logo, children }: HeaderModel) {
|
|
34
|
+
return (
|
|
35
|
+
<header>
|
|
36
|
+
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-3 border-b border-border bg-surface px-4 py-3">
|
|
37
|
+
<a href={homeHref} className="group inline-flex items-center gap-3 hover:text-primary">
|
|
38
|
+
<span
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
className="size-3.5 rotate-45 border-2 border-primary bg-primary/25 group-hover:bg-primary/50"
|
|
41
|
+
/>
|
|
42
|
+
<BoardMark boardTitle={boardTitle} logo={logo} />
|
|
43
|
+
</a>
|
|
44
|
+
{children}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div className={RULE} aria-hidden="true" />
|
|
48
|
+
|
|
49
|
+
{navigation.length > 0 && (
|
|
50
|
+
<nav
|
|
51
|
+
aria-label="Board sections"
|
|
52
|
+
className="flex flex-wrap border-b border-border bg-card/60"
|
|
53
|
+
>
|
|
54
|
+
{navigation.map((item) => (
|
|
55
|
+
<a
|
|
56
|
+
key={item.href}
|
|
57
|
+
href={item.href}
|
|
58
|
+
className="border-t-2 border-r border-t-transparent border-r-border px-3.5 py-2 font-mono text-[0.6875rem] font-semibold tracking-[0.14em] text-muted-foreground uppercase hover:border-t-primary hover:bg-secondary hover:text-primary"
|
|
59
|
+
>
|
|
60
|
+
{item.label}
|
|
61
|
+
</a>
|
|
62
|
+
))}
|
|
63
|
+
</nav>
|
|
64
|
+
)}
|
|
65
|
+
</header>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { LatestPostsModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { Frame, MICRO, PanelHead, Stamp, UserRef } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function LatestPosts({ posts, capturedAt }: LatestPostsModel) {
|
|
6
|
+
return (
|
|
7
|
+
<Frame aria-labelledby="latest-posts-heading">
|
|
8
|
+
<PanelHead id="latest-posts-heading" title="Latest posts" />
|
|
9
|
+
|
|
10
|
+
{posts.length === 0 ? (
|
|
11
|
+
<p className={`${MICRO} px-3 py-2.5`}>nothing said yet</p>
|
|
12
|
+
) : (
|
|
13
|
+
<ul className="divide-y divide-border">
|
|
14
|
+
{posts.map((post) => (
|
|
15
|
+
<li key={post.href} className="px-3 py-2 hover:bg-secondary/50">
|
|
16
|
+
<a href={post.href} className="block truncate text-xs text-foreground hover:text-primary">
|
|
17
|
+
{post.threadTitle}
|
|
18
|
+
</a>
|
|
19
|
+
<p className={`${MICRO} mt-0.5 truncate normal-case`}>
|
|
20
|
+
<a href={post.forum.href} className="uppercase hover:text-primary">
|
|
21
|
+
{post.forum.label}
|
|
22
|
+
</a>
|
|
23
|
+
<span className="text-border">{' | '}</span>
|
|
24
|
+
<UserRef user={post.author} className="hover:text-primary" />
|
|
25
|
+
</p>
|
|
26
|
+
<p className={`${MICRO} truncate normal-case`}>
|
|
27
|
+
<Stamp at={post.postedAt} />
|
|
28
|
+
</p>
|
|
29
|
+
{post.excerpt !== '' && (
|
|
30
|
+
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{post.excerpt}</p>
|
|
31
|
+
)}
|
|
32
|
+
</li>
|
|
33
|
+
))}
|
|
34
|
+
</ul>
|
|
35
|
+
)}
|
|
36
|
+
|
|
37
|
+
<p className={`${MICRO} border-t border-border px-3 py-1.5 normal-case`}>
|
|
38
|
+
<span className="uppercase">as of</span> <Stamp at={capturedAt} />
|
|
39
|
+
</p>
|
|
40
|
+
</Frame>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { LatestThreadsModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { Frame, MICRO, NUMERIC, PanelHead, Stamp, UserRef } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function LatestThreads({ threads, capturedAt }: LatestThreadsModel) {
|
|
6
|
+
return (
|
|
7
|
+
<Frame aria-labelledby="latest-threads-heading">
|
|
8
|
+
<PanelHead id="latest-threads-heading" title="Latest threads" />
|
|
9
|
+
|
|
10
|
+
{threads.length === 0 ? (
|
|
11
|
+
<p className={`${MICRO} px-3 py-2.5`}>nothing started yet</p>
|
|
12
|
+
) : (
|
|
13
|
+
<ul className="divide-y divide-border">
|
|
14
|
+
{threads.map((thread) => (
|
|
15
|
+
<li key={thread.href} className="px-3 py-2 hover:bg-secondary/50">
|
|
16
|
+
<a href={thread.href} className="block truncate text-xs text-foreground hover:text-primary">
|
|
17
|
+
{thread.title}
|
|
18
|
+
</a>
|
|
19
|
+
<p className={`${MICRO} mt-0.5 truncate normal-case`}>
|
|
20
|
+
<a href={thread.forum.href} className="uppercase hover:text-primary">
|
|
21
|
+
{thread.forum.label}
|
|
22
|
+
</a>
|
|
23
|
+
<span className="text-border">{' | '}</span>
|
|
24
|
+
<UserRef user={thread.author} className="hover:text-primary" />
|
|
25
|
+
</p>
|
|
26
|
+
<p className={`${MICRO} truncate normal-case`}>
|
|
27
|
+
<Stamp at={thread.startedAt} />
|
|
28
|
+
<span className="text-border">{' | '}</span>
|
|
29
|
+
<span className={NUMERIC}>{thread.replyCount}</span>
|
|
30
|
+
<span className="uppercase"> replies</span>
|
|
31
|
+
</p>
|
|
32
|
+
</li>
|
|
33
|
+
))}
|
|
34
|
+
</ul>
|
|
35
|
+
)}
|
|
36
|
+
|
|
37
|
+
<p className={`${MICRO} border-t border-border px-3 py-1.5 normal-case`}>
|
|
38
|
+
<span className="uppercase">as of</span> <Stamp at={capturedAt} />
|
|
39
|
+
</p>
|
|
40
|
+
</Frame>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { MemberProfileModel } from '@meith/theme-kit'
|
|
4
|
+
|
|
5
|
+
import { BUTTON, Frame, HEADING, MICRO, NUMERIC, PanelHead, RULE, Stamp } from '../shared'
|
|
6
|
+
|
|
7
|
+
function Field({ label, children }: { label: string; children: ReactNode }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="border border-border bg-surface px-2.5 py-2">
|
|
10
|
+
<dt className={MICRO}>{label}</dt>
|
|
11
|
+
<dd className={`${NUMERIC} mt-0.5 text-sm text-foreground`}>{children}</dd>
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function MemberProfile({
|
|
17
|
+
user,
|
|
18
|
+
avatarUrl,
|
|
19
|
+
title,
|
|
20
|
+
joinedAt,
|
|
21
|
+
lastVisitAt,
|
|
22
|
+
postCount,
|
|
23
|
+
signatureHtml,
|
|
24
|
+
fields,
|
|
25
|
+
actions,
|
|
26
|
+
regions,
|
|
27
|
+
}: MemberProfileModel) {
|
|
28
|
+
return (
|
|
29
|
+
<article className="flex flex-col gap-4 p-4">
|
|
30
|
+
<div className="border border-border bg-card shadow-elevation">
|
|
31
|
+
<div className="flex flex-wrap items-center gap-4 px-4 py-4">
|
|
32
|
+
{avatarUrl !== null && (
|
|
33
|
+
<span className="relative inline-block border border-primary/50 p-1">
|
|
34
|
+
<img src={avatarUrl} alt="" width={72} height={72} className="size-18 object-cover" />
|
|
35
|
+
<span
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
className="pointer-events-none absolute -top-px -left-px size-2 border-t-2 border-l-2 border-primary"
|
|
38
|
+
/>
|
|
39
|
+
<span
|
|
40
|
+
aria-hidden="true"
|
|
41
|
+
className="pointer-events-none absolute -right-px -bottom-px size-2 border-r-2 border-b-2 border-primary"
|
|
42
|
+
/>
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
<div className="min-w-0">
|
|
47
|
+
<p className={MICRO}>profile</p>
|
|
48
|
+
<h1
|
|
49
|
+
className={[`${HEADING} text-2xl text-foreground`, user.nameClass]
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join(' ')}
|
|
52
|
+
>
|
|
53
|
+
{user.username}
|
|
54
|
+
</h1>
|
|
55
|
+
{title !== null && <p className={`${MICRO} mt-0.5 text-primary/90`}>{title}</p>}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div className={RULE} aria-hidden="true" />
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<Frame aria-labelledby="member-stats-heading">
|
|
62
|
+
<PanelHead id="member-stats-heading" title="Statistics" />
|
|
63
|
+
<dl className="grid grid-cols-2 gap-1.5 p-3 sm:grid-cols-3">
|
|
64
|
+
<Field label="posts">{postCount}</Field>
|
|
65
|
+
<Field label="joined">
|
|
66
|
+
<Stamp at={joinedAt} />
|
|
67
|
+
</Field>
|
|
68
|
+
<Field label="last visit">
|
|
69
|
+
{lastVisitAt === null ? 'never' : <Stamp at={lastVisitAt} />}
|
|
70
|
+
</Field>
|
|
71
|
+
{fields.map((field) => (
|
|
72
|
+
<Field key={field.label} label={field.label}>
|
|
73
|
+
{field.value}
|
|
74
|
+
</Field>
|
|
75
|
+
))}
|
|
76
|
+
</dl>
|
|
77
|
+
</Frame>
|
|
78
|
+
|
|
79
|
+
{signatureHtml !== null && (
|
|
80
|
+
<Frame aria-labelledby="member-signature-heading">
|
|
81
|
+
<PanelHead id="member-signature-heading" title="Signature" />
|
|
82
|
+
<div
|
|
83
|
+
className="prose-md p-3 text-sm"
|
|
84
|
+
dangerouslySetInnerHTML={{ __html: signatureHtml }}
|
|
85
|
+
/>
|
|
86
|
+
</Frame>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
{regions?.plugins}
|
|
90
|
+
|
|
91
|
+
{actions.length > 0 && (
|
|
92
|
+
<nav aria-label="Member actions" className="flex flex-wrap gap-2">
|
|
93
|
+
{actions.map((action) => (
|
|
94
|
+
<a key={action.href} href={action.href} className={BUTTON}>
|
|
95
|
+
{action.label}
|
|
96
|
+
</a>
|
|
97
|
+
))}
|
|
98
|
+
</nav>
|
|
99
|
+
)}
|
|
100
|
+
</article>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { NavigationModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
export function Navigation({ items }: NavigationModel) {
|
|
4
|
+
return (
|
|
5
|
+
<nav aria-label="Breadcrumb" className="border-b border-border bg-card/40 px-4 py-2">
|
|
6
|
+
<ol className="flex flex-wrap items-center gap-1.5 font-mono text-[0.6875rem] tracking-[0.1em] text-muted-foreground uppercase">
|
|
7
|
+
{items.map((item, index) => {
|
|
8
|
+
const isLast = index === items.length - 1
|
|
9
|
+
return (
|
|
10
|
+
<li key={item.href} className="flex items-center gap-1.5">
|
|
11
|
+
{index > 0 && (
|
|
12
|
+
<span aria-hidden="true" className="text-primary/60">
|
|
13
|
+
›
|
|
14
|
+
</span>
|
|
15
|
+
)}
|
|
16
|
+
{isLast ? (
|
|
17
|
+
<span aria-current="page" className="font-semibold text-foreground">
|
|
18
|
+
{item.label}
|
|
19
|
+
</span>
|
|
20
|
+
) : (
|
|
21
|
+
<a href={item.href} className="hover:text-primary">
|
|
22
|
+
{item.label}
|
|
23
|
+
</a>
|
|
24
|
+
)}
|
|
25
|
+
</li>
|
|
26
|
+
)
|
|
27
|
+
})}
|
|
28
|
+
</ol>
|
|
29
|
+
</nav>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { NoticeModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
const TONE: Record<NoticeModel['kind'], string> = {
|
|
4
|
+
info: 'border-l-forum-unread text-forum-unread',
|
|
5
|
+
success: 'border-l-moderation-approved text-moderation-approved',
|
|
6
|
+
warning: 'border-l-thread-pinned text-thread-pinned',
|
|
7
|
+
error: 'border-l-destructive text-destructive',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Notice({ kind, message, dismissHref }: NoticeModel) {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
role={kind === 'error' ? 'alert' : undefined}
|
|
14
|
+
className={`flex items-start justify-between gap-3 border border-l-[3px] border-border bg-card px-3 py-2 text-sm shadow-elevation ${TONE[kind]}`}
|
|
15
|
+
>
|
|
16
|
+
<p className="text-foreground">
|
|
17
|
+
<span className="mr-2 font-mono text-[0.625rem] font-bold tracking-[0.18em] uppercase">
|
|
18
|
+
{kind}
|
|
19
|
+
</span>
|
|
20
|
+
{message}
|
|
21
|
+
</p>
|
|
22
|
+
{dismissHref !== null && (
|
|
23
|
+
<a
|
|
24
|
+
href={dismissHref}
|
|
25
|
+
className="font-mono text-[0.625rem] font-semibold tracking-[0.14em] text-muted-foreground uppercase hover:text-primary"
|
|
26
|
+
>
|
|
27
|
+
dismiss
|
|
28
|
+
</a>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PaginationModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { MICRO, NUMERIC } from '../shared'
|
|
4
|
+
|
|
5
|
+
const STEP =
|
|
6
|
+
'inline-flex min-w-8 items-center justify-center border border-border bg-card px-2 py-1 font-mono text-[0.6875rem] font-semibold uppercase tracking-[0.12em] tabular-nums text-muted-foreground hover:border-primary/60 hover:text-primary'
|
|
7
|
+
|
|
8
|
+
export function Pagination({ page, pageCount, pages, previousHref, nextHref }: PaginationModel) {
|
|
9
|
+
if (pageCount <= 1) return null
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<nav aria-label="Pagination" className="flex flex-wrap items-center gap-1.5">
|
|
13
|
+
{previousHref !== null && (
|
|
14
|
+
<a href={previousHref} rel="prev" className={STEP}>
|
|
15
|
+
prev
|
|
16
|
+
</a>
|
|
17
|
+
)}
|
|
18
|
+
|
|
19
|
+
{pages.map((entry) =>
|
|
20
|
+
entry.isCurrent ? (
|
|
21
|
+
<span
|
|
22
|
+
key={entry.href}
|
|
23
|
+
aria-current="page"
|
|
24
|
+
className={`${STEP} border-primary bg-primary text-primary-foreground hover:text-primary-foreground`}
|
|
25
|
+
>
|
|
26
|
+
{entry.page}
|
|
27
|
+
</span>
|
|
28
|
+
) : (
|
|
29
|
+
<a key={entry.href} href={entry.href} className={STEP}>
|
|
30
|
+
{entry.page}
|
|
31
|
+
</a>
|
|
32
|
+
),
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
{nextHref !== null && (
|
|
36
|
+
<a href={nextHref} rel="next" className={STEP}>
|
|
37
|
+
next
|
|
38
|
+
</a>
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
<span className={`${MICRO} ml-2 normal-case`}>
|
|
42
|
+
<span className="uppercase">page</span> <span className={NUMERIC}>{page}</span>
|
|
43
|
+
{' / '}
|
|
44
|
+
<span className={NUMERIC}>{pageCount}</span>
|
|
45
|
+
</span>
|
|
46
|
+
</nav>
|
|
47
|
+
)
|
|
48
|
+
}
|