@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,110 @@
|
|
|
1
|
+
import type { PanelNavItemModel, PanelNavModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { MICRO, NUMERIC, RULE } from '../shared'
|
|
4
|
+
|
|
5
|
+
const ITEM =
|
|
6
|
+
'flex items-baseline gap-2 border-l-2 px-2.5 py-1.5 text-[0.8125rem] tracking-[0.02em]'
|
|
7
|
+
|
|
8
|
+
const HERE = 'border-l-primary bg-secondary font-semibold text-foreground'
|
|
9
|
+
const OPEN = 'border-l-primary/40 font-semibold text-foreground'
|
|
10
|
+
const ELSEWHERE = 'border-l-transparent text-muted-foreground hover:border-l-border hover:text-primary'
|
|
11
|
+
|
|
12
|
+
function Count({ count }: { count: number }) {
|
|
13
|
+
return (
|
|
14
|
+
<>
|
|
15
|
+
<span
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
className={`ml-auto border border-primary/50 bg-primary/15 px-1.5 text-[0.625rem] font-bold text-primary ${NUMERIC}`}
|
|
18
|
+
>
|
|
19
|
+
{count > 99 ? '99+' : count}
|
|
20
|
+
</span>
|
|
21
|
+
<span className="sr-only">({count} waiting)</span>
|
|
22
|
+
</>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Item({ item, className }: { item: PanelNavItemModel; className: string }) {
|
|
27
|
+
const body = (
|
|
28
|
+
<>
|
|
29
|
+
<span className="min-w-0 flex-1">{item.title}</span>
|
|
30
|
+
{item.count !== null && <Count count={item.count} />}
|
|
31
|
+
</>
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if (item.isRecord) {
|
|
35
|
+
return (
|
|
36
|
+
<span className={`${ITEM} ${HERE} ${className}`} aria-current="page">
|
|
37
|
+
{body}
|
|
38
|
+
</span>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<a
|
|
44
|
+
href={item.href}
|
|
45
|
+
className={`${ITEM} ${className}`}
|
|
46
|
+
{...(item.current === null
|
|
47
|
+
? {}
|
|
48
|
+
: { 'aria-current': item.current === 'here' ? ('page' as const) : ('true' as const) })}
|
|
49
|
+
>
|
|
50
|
+
{body}
|
|
51
|
+
</a>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Sections({ label, sections }: Pick<PanelNavModel, 'label' | 'sections'>) {
|
|
56
|
+
return (
|
|
57
|
+
<nav aria-label={label}>
|
|
58
|
+
<ul className="flex flex-col">
|
|
59
|
+
{sections.map((section) => (
|
|
60
|
+
<li
|
|
61
|
+
key={section.href}
|
|
62
|
+
className={section.isOverview ? 'mb-1 border-b border-border pb-1' : undefined}
|
|
63
|
+
>
|
|
64
|
+
<Item
|
|
65
|
+
item={section}
|
|
66
|
+
className={section.current === 'here' ? HERE : section.isOpen ? OPEN : ELSEWHERE}
|
|
67
|
+
/>
|
|
68
|
+
|
|
69
|
+
{section.isOpen && section.children.length > 0 && (
|
|
70
|
+
<ul className="my-0.5 ml-3 flex flex-col border-l border-border pl-1">
|
|
71
|
+
{section.children.map((child) => (
|
|
72
|
+
<li key={child.href}>
|
|
73
|
+
<Item item={child} className={child.current === 'here' ? HERE : ELSEWHERE} />
|
|
74
|
+
</li>
|
|
75
|
+
))}
|
|
76
|
+
</ul>
|
|
77
|
+
)}
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ul>
|
|
81
|
+
</nav>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function PanelNav({ label, sections, currentTitle }: PanelNavModel) {
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<details className="border border-border bg-card lg:hidden">
|
|
89
|
+
<summary className="flex cursor-pointer items-center justify-between gap-2 bg-surface px-3 py-2">
|
|
90
|
+
<span className={`${MICRO} text-foreground`}>{label}</span>
|
|
91
|
+
{currentTitle !== null && <span className={MICRO}>{currentTitle}</span>}
|
|
92
|
+
</summary>
|
|
93
|
+
<div className={RULE} aria-hidden="true" />
|
|
94
|
+
<div className="p-2">
|
|
95
|
+
<Sections label={label} sections={sections} />
|
|
96
|
+
</div>
|
|
97
|
+
</details>
|
|
98
|
+
|
|
99
|
+
<div className="hidden border border-border bg-card shadow-elevation lg:block">
|
|
100
|
+
<p className={`${MICRO} border-b border-border bg-surface px-3 py-2 text-foreground`}>
|
|
101
|
+
{label}
|
|
102
|
+
</p>
|
|
103
|
+
<div className={RULE} aria-hidden="true" />
|
|
104
|
+
<div className="p-2">
|
|
105
|
+
<Sections label={label} sections={sections} />
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { PanelKind, PanelPageModel, PanelSectionModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { HEADING, MICRO, RULE } from '../shared'
|
|
4
|
+
|
|
5
|
+
const LABEL: Record<PanelKind, string> = {
|
|
6
|
+
usercp: 'your control panel',
|
|
7
|
+
modcp: 'moderator control panel',
|
|
8
|
+
admincp: 'control panel',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function PanelPage({ panel, title, back, width, gap, regions, children }: PanelPageModel) {
|
|
12
|
+
return (
|
|
13
|
+
<main
|
|
14
|
+
id="board-content"
|
|
15
|
+
tabIndex={-1}
|
|
16
|
+
className={`flex w-full flex-col p-4 ${width === 'wide' ? 'max-w-none' : 'max-w-4xl'} ${
|
|
17
|
+
gap === 'loose' ? 'gap-6' : 'gap-4'
|
|
18
|
+
}`}
|
|
19
|
+
>
|
|
20
|
+
<div className="border border-border bg-card shadow-elevation">
|
|
21
|
+
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2 px-4 py-3">
|
|
22
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
23
|
+
{back !== null && (
|
|
24
|
+
<a href={back.href} className={`${MICRO} hover:text-primary`}>
|
|
25
|
+
← {back.label}
|
|
26
|
+
</a>
|
|
27
|
+
)}
|
|
28
|
+
|
|
29
|
+
{panel !== null && back === null && <p className={MICRO}>{LABEL[panel]}</p>}
|
|
30
|
+
|
|
31
|
+
<h1 className={`${HEADING} text-xl text-foreground`}>{title}</h1>
|
|
32
|
+
|
|
33
|
+
{regions.lede !== undefined && (
|
|
34
|
+
<p className="text-sm text-muted-foreground">{regions.lede}</p>
|
|
35
|
+
)}
|
|
36
|
+
{regions.meta !== undefined && <p className={`${MICRO} normal-case`}>{regions.meta}</p>}
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
{regions.actions !== undefined && (
|
|
40
|
+
<div className="flex shrink-0 flex-wrap items-center gap-2">{regions.actions}</div>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
<div className={RULE} aria-hidden="true" />
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{children}
|
|
47
|
+
</main>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function PanelSection({ title, headingId, regions, children }: PanelSectionModel) {
|
|
52
|
+
return (
|
|
53
|
+
<section aria-labelledby={headingId} className="flex flex-col gap-3">
|
|
54
|
+
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-1 border-b border-border pb-1.5">
|
|
55
|
+
<div className="flex min-w-0 flex-col gap-0.5">
|
|
56
|
+
<h2 id={headingId} className={`${HEADING} text-base text-foreground`}>
|
|
57
|
+
{title}
|
|
58
|
+
</h2>
|
|
59
|
+
{regions.description !== undefined && (
|
|
60
|
+
<p className="text-sm text-muted-foreground">{regions.description}</p>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{regions.actions !== undefined && (
|
|
65
|
+
<div className="flex shrink-0 flex-wrap items-center gap-2">{regions.actions}</div>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{children}
|
|
70
|
+
</section>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PanelShellModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { MICRO } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function PanelShell({ panel, links, linksLabel, regions, children }: PanelShellModel) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col lg:flex-row">
|
|
8
|
+
<aside
|
|
9
|
+
className={`flex flex-col gap-3 p-4 lg:sticky lg:w-56 lg:shrink-0 lg:self-start lg:pr-0 ${
|
|
10
|
+
panel === 'admincp' ? 'lg:top-14' : 'lg:top-4'
|
|
11
|
+
}`}
|
|
12
|
+
>
|
|
13
|
+
{regions.nav}
|
|
14
|
+
|
|
15
|
+
{links.length > 0 && (
|
|
16
|
+
<nav aria-label={linksLabel} className="border-t border-border pt-3">
|
|
17
|
+
<ul className="flex flex-col gap-1">
|
|
18
|
+
{links.map((link) => (
|
|
19
|
+
<li key={link.href}>
|
|
20
|
+
<a href={link.href} className={`${MICRO} block hover:text-primary`}>
|
|
21
|
+
{link.label}
|
|
22
|
+
</a>
|
|
23
|
+
</li>
|
|
24
|
+
))}
|
|
25
|
+
</ul>
|
|
26
|
+
</nav>
|
|
27
|
+
)}
|
|
28
|
+
</aside>
|
|
29
|
+
|
|
30
|
+
<div className="min-w-0 flex-1">{children}</div>
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { PostActionsSlotModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
const ACTION =
|
|
4
|
+
'font-mono text-[0.625rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-primary'
|
|
5
|
+
|
|
6
|
+
export function PostActions({ actions, children }: PostActionsSlotModel) {
|
|
7
|
+
const items = [
|
|
8
|
+
{ href: actions.quoteHref, label: 'quote' },
|
|
9
|
+
{ href: actions.editHref, label: 'edit' },
|
|
10
|
+
{ href: actions.restoreHref, label: 'restore' },
|
|
11
|
+
{ href: actions.rateHref, label: 'rate' },
|
|
12
|
+
{ href: actions.reportHref, label: 'report' },
|
|
13
|
+
{ href: actions.warnHref, label: 'warn' },
|
|
14
|
+
{ href: actions.moderateHref, label: 'moderate' },
|
|
15
|
+
].filter((item): item is { href: string; label: string } => item.href !== null)
|
|
16
|
+
|
|
17
|
+
if (items.length === 0 && children === undefined) return null
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<nav aria-label="Post actions" className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
21
|
+
{items.map((item) => (
|
|
22
|
+
<a key={item.label} href={item.href} className={ACTION}>
|
|
23
|
+
{item.label}
|
|
24
|
+
</a>
|
|
25
|
+
))}
|
|
26
|
+
{children}
|
|
27
|
+
</nav>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { PostBitSlotModel } from '@meith/theme-kit'
|
|
4
|
+
|
|
5
|
+
import { HEADING, MICRO, NUMERIC, Stamp, UserRef } from '../shared'
|
|
6
|
+
|
|
7
|
+
function GroupBadge({ badge }: { badge: NonNullable<PostBitSlotModel['post']['author']['badge']> }) {
|
|
8
|
+
const image = (
|
|
9
|
+
<img
|
|
10
|
+
src={badge.src}
|
|
11
|
+
alt=""
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
className="h-4 w-auto max-w-full object-contain"
|
|
14
|
+
loading="lazy"
|
|
15
|
+
decoding="async"
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if (badge.darkSrc === null) return image
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<picture>
|
|
23
|
+
<source media="(prefers-color-scheme: dark)" srcSet={badge.darkSrc} />
|
|
24
|
+
{image}
|
|
25
|
+
</picture>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function StatusBanner({ visibility }: { visibility: PostBitSlotModel['post']['visibility'] }) {
|
|
30
|
+
if (visibility === 'visible') return null
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<p
|
|
34
|
+
className={`${MICRO} border-b border-border px-3 py-1.5 ${
|
|
35
|
+
visibility === 'deleted' ? 'bg-thread-deleted/10' : 'bg-post-unapproved'
|
|
36
|
+
}`}
|
|
37
|
+
>
|
|
38
|
+
{visibility === 'deleted'
|
|
39
|
+
? 'deleted — visible to moderators only'
|
|
40
|
+
: 'awaiting approval — visible to moderators only'}
|
|
41
|
+
</p>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function StatLine({ label, children }: { label: string; children: ReactNode }) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex items-baseline justify-between gap-2 border-t border-border/60 py-0.5">
|
|
48
|
+
<dt className={MICRO}>{label}</dt>
|
|
49
|
+
<dd className={`${NUMERIC} text-[0.6875rem] text-foreground`}>{children}</dd>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function PostBit({ post, select, regions }: PostBitSlotModel) {
|
|
55
|
+
return (
|
|
56
|
+
<article
|
|
57
|
+
id={`post-${post.number}`}
|
|
58
|
+
data-post-id={post.id}
|
|
59
|
+
className="relative border border-border bg-card shadow-elevation"
|
|
60
|
+
>
|
|
61
|
+
<span
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
className="pointer-events-none absolute -top-px -left-px size-2 border-t-2 border-l-2 border-primary/70"
|
|
64
|
+
/>
|
|
65
|
+
<span
|
|
66
|
+
aria-hidden="true"
|
|
67
|
+
className="pointer-events-none absolute -right-px -bottom-px size-2 border-r-2 border-b-2 border-primary/70"
|
|
68
|
+
/>
|
|
69
|
+
|
|
70
|
+
<StatusBanner visibility={post.visibility} />
|
|
71
|
+
|
|
72
|
+
<div className="grid grid-cols-1 sm:grid-cols-[12rem_1fr]">
|
|
73
|
+
<div className="border-b border-border bg-surface px-3 py-3 sm:border-r sm:border-b-0">
|
|
74
|
+
<div className="flex items-start gap-2">
|
|
75
|
+
{select !== null && (
|
|
76
|
+
<label className="flex items-center pt-1">
|
|
77
|
+
<span className="sr-only">{select.label}</span>
|
|
78
|
+
<input
|
|
79
|
+
type="checkbox"
|
|
80
|
+
name={select.name}
|
|
81
|
+
value={select.value}
|
|
82
|
+
form={select.formId}
|
|
83
|
+
className="size-3.5 accent-primary"
|
|
84
|
+
/>
|
|
85
|
+
</label>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
{post.author.avatarUrl !== null && (
|
|
89
|
+
<span className="relative inline-block shrink-0 border border-primary/50 p-0.5">
|
|
90
|
+
<img
|
|
91
|
+
src={post.author.avatarUrl}
|
|
92
|
+
alt=""
|
|
93
|
+
width={48}
|
|
94
|
+
height={48}
|
|
95
|
+
className="size-12 object-cover"
|
|
96
|
+
/>
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<div className="min-w-0">
|
|
101
|
+
<p>
|
|
102
|
+
<UserRef
|
|
103
|
+
user={post.author}
|
|
104
|
+
className={`${HEADING} text-sm ${
|
|
105
|
+
post.author.profileHref === null ? '' : 'hover:text-primary'
|
|
106
|
+
}`}
|
|
107
|
+
/>
|
|
108
|
+
</p>
|
|
109
|
+
{post.author.title !== null && (
|
|
110
|
+
<p className={`${MICRO} mt-0.5 text-primary/90`}>{post.author.title}</p>
|
|
111
|
+
)}
|
|
112
|
+
{post.author.isOnline && (
|
|
113
|
+
<p className="mt-1 inline-flex items-center gap-1.5">
|
|
114
|
+
<span
|
|
115
|
+
aria-hidden="true"
|
|
116
|
+
className="size-1.5 bg-moderation-approved shadow-[0_0_6px_var(--color-moderation-approved)]"
|
|
117
|
+
/>
|
|
118
|
+
<span
|
|
119
|
+
className={`${MICRO} text-moderation-approved`}
|
|
120
|
+
>
|
|
121
|
+
online
|
|
122
|
+
</span>
|
|
123
|
+
</p>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{regions.pluginBadges}
|
|
129
|
+
|
|
130
|
+
{post.author.badge != null && (
|
|
131
|
+
<p className="mt-2">
|
|
132
|
+
<GroupBadge badge={post.author.badge} />
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
<dl className="mt-2.5">
|
|
137
|
+
<StatLine label="posts">{post.author.postCount}</StatLine>
|
|
138
|
+
{post.author.reputation != null && (
|
|
139
|
+
<StatLine label="rep">{post.author.reputation}</StatLine>
|
|
140
|
+
)}
|
|
141
|
+
{post.author.joinedAt !== null && (
|
|
142
|
+
<StatLine label="joined">
|
|
143
|
+
<Stamp at={post.author.joinedAt} />
|
|
144
|
+
</StatLine>
|
|
145
|
+
)}
|
|
146
|
+
{post.author.fields.map((field) => (
|
|
147
|
+
<StatLine key={field.label} label={field.label}>
|
|
148
|
+
{field.value}
|
|
149
|
+
</StatLine>
|
|
150
|
+
))}
|
|
151
|
+
</dl>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div className="min-w-0">
|
|
155
|
+
<div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-1.5">
|
|
156
|
+
<Stamp at={post.postedAt} className={`${MICRO} normal-case`} />
|
|
157
|
+
<a
|
|
158
|
+
href={post.permalink}
|
|
159
|
+
className={`${NUMERIC} text-[0.6875rem] font-bold text-primary hover:underline`}
|
|
160
|
+
>
|
|
161
|
+
#{post.number}
|
|
162
|
+
</a>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{post.ignored !== null ? (
|
|
166
|
+
<p className="px-3 py-3 text-sm text-muted-foreground">
|
|
167
|
+
Post from {post.ignored.authorUsername}, who you are ignoring.{' '}
|
|
168
|
+
<a href={post.ignored.revealHref} className="text-primary hover:underline">
|
|
169
|
+
Show it
|
|
170
|
+
</a>
|
|
171
|
+
</p>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<div
|
|
175
|
+
className="prose-md px-3 py-3 text-sm"
|
|
176
|
+
dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
|
|
177
|
+
/>
|
|
178
|
+
|
|
179
|
+
{post.attachments.length > 0 && (
|
|
180
|
+
<ul className="flex flex-wrap gap-2 border-t border-border px-3 py-2">
|
|
181
|
+
{post.attachments.map((attachment) => (
|
|
182
|
+
<li key={attachment.id}>
|
|
183
|
+
<a
|
|
184
|
+
href={attachment.href}
|
|
185
|
+
className="inline-flex items-center gap-2 border border-border bg-surface px-2 py-1 font-mono text-[0.625rem] tracking-[0.1em] uppercase hover:border-primary/60 hover:text-primary"
|
|
186
|
+
>
|
|
187
|
+
{attachment.filename}
|
|
188
|
+
<span className={`${NUMERIC} text-muted-foreground`}>
|
|
189
|
+
{attachment.size}
|
|
190
|
+
</span>
|
|
191
|
+
</a>
|
|
192
|
+
</li>
|
|
193
|
+
))}
|
|
194
|
+
</ul>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{post.author.signatureHtml !== null && (
|
|
198
|
+
<div
|
|
199
|
+
className="border-t border-border/60 px-3 py-2 text-xs text-muted-foreground"
|
|
200
|
+
dangerouslySetInnerHTML={{ __html: post.author.signatureHtml }}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{post.editedNote !== null && (
|
|
207
|
+
<p className={`${MICRO} border-t border-border px-3 py-1.5 normal-case`}>
|
|
208
|
+
{post.editedNote}
|
|
209
|
+
</p>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{regions.pluginFooter !== undefined && regions.pluginFooter !== null && (
|
|
213
|
+
<div className="border-t border-border px-3 py-1.5 text-xs">{regions.pluginFooter}</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
{regions.actions !== null && (
|
|
217
|
+
<div className="border-t border-border bg-surface/60 px-3 py-1.5 empty:hidden">
|
|
218
|
+
{regions.actions}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</article>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { PostFormModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { BUTTON, HEADING, MICRO, RULE } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function PostForm({
|
|
6
|
+
mode,
|
|
7
|
+
heading,
|
|
8
|
+
cancelHref,
|
|
9
|
+
cancelLabel,
|
|
10
|
+
errorMessage,
|
|
11
|
+
regions,
|
|
12
|
+
}: PostFormModel) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
|
15
|
+
{errorMessage !== null && (
|
|
16
|
+
<p
|
|
17
|
+
role="alert"
|
|
18
|
+
className="border border-l-[3px] border-border border-l-destructive bg-card px-3 py-2 text-sm"
|
|
19
|
+
>
|
|
20
|
+
<span className={`${MICRO} mr-2 text-destructive`}>cannot post</span>
|
|
21
|
+
{errorMessage}
|
|
22
|
+
</p>
|
|
23
|
+
)}
|
|
24
|
+
|
|
25
|
+
<section className="border border-border bg-card shadow-elevation">
|
|
26
|
+
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
|
27
|
+
<div>
|
|
28
|
+
<p className={MICRO}>{mode === 'edit' ? 'edit' : mode === 'reply' ? 'reply' : 'new thread'}</p>
|
|
29
|
+
<h1 className={`${HEADING} text-lg text-foreground`}>{heading}</h1>
|
|
30
|
+
</div>
|
|
31
|
+
<a href={cancelHref} className={BUTTON}>
|
|
32
|
+
{cancelLabel}
|
|
33
|
+
</a>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div className={RULE} aria-hidden="true" />
|
|
37
|
+
|
|
38
|
+
{regions.toolbar}
|
|
39
|
+
|
|
40
|
+
<div className="px-4 py-4">{regions.form}</div>
|
|
41
|
+
</section>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RedirectNoticeModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { BUTTON_PRIMARY, Frame, HEADING, MICRO, NUMERIC } from '../shared'
|
|
4
|
+
|
|
5
|
+
export function RedirectNotice({ message, targetHref, delaySeconds }: RedirectNoticeModel) {
|
|
6
|
+
return (
|
|
7
|
+
<Frame className="w-full max-w-lg">
|
|
8
|
+
<div className="px-5 py-5">
|
|
9
|
+
<p className={`${MICRO} text-primary`}>redirecting</p>
|
|
10
|
+
<h1 className={`${HEADING} mt-1 text-2xl text-foreground`}>Please wait</h1>
|
|
11
|
+
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
|
|
12
|
+
|
|
13
|
+
<div className="mt-5 flex flex-wrap items-center gap-3">
|
|
14
|
+
<a href={targetHref} className={BUTTON_PRIMARY}>
|
|
15
|
+
continue now
|
|
16
|
+
</a>
|
|
17
|
+
<span className={`${MICRO} normal-case`}>
|
|
18
|
+
<span className="uppercase">continuing in</span>{' '}
|
|
19
|
+
<span className={NUMERIC}>{delaySeconds}</span>
|
|
20
|
+
{delaySeconds === 1 ? ' second' : ' seconds'}
|
|
21
|
+
</span>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</Frame>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { OptionModel, SearchFormModel } from '@meith/theme-kit'
|
|
2
|
+
|
|
3
|
+
import { BUTTON_PRIMARY, Frame, MICRO, PanelHead } from '../shared'
|
|
4
|
+
|
|
5
|
+
const CONTROL =
|
|
6
|
+
'w-full border border-input bg-surface px-3 py-2 text-sm text-foreground focus-visible:border-ring'
|
|
7
|
+
|
|
8
|
+
function Choice({
|
|
9
|
+
id,
|
|
10
|
+
label,
|
|
11
|
+
name,
|
|
12
|
+
options,
|
|
13
|
+
}: {
|
|
14
|
+
id: string
|
|
15
|
+
label: string
|
|
16
|
+
name: string
|
|
17
|
+
options: readonly OptionModel[]
|
|
18
|
+
}) {
|
|
19
|
+
const selected = options.find((option) => option.isSelected)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div>
|
|
23
|
+
<label htmlFor={id} className={`${MICRO} mb-1 block`}>
|
|
24
|
+
{label}
|
|
25
|
+
</label>
|
|
26
|
+
<select id={id} name={name} defaultValue={selected?.value ?? ''} className={CONTROL}>
|
|
27
|
+
{options.map((option) => (
|
|
28
|
+
<option key={option.value} value={option.value}>
|
|
29
|
+
{option.label}
|
|
30
|
+
</option>
|
|
31
|
+
))}
|
|
32
|
+
</select>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function SearchForm({
|
|
38
|
+
action,
|
|
39
|
+
fields,
|
|
40
|
+
query,
|
|
41
|
+
maxQueryLength,
|
|
42
|
+
forums,
|
|
43
|
+
sorts,
|
|
44
|
+
hint,
|
|
45
|
+
errorMessage,
|
|
46
|
+
}: SearchFormModel) {
|
|
47
|
+
const described = [
|
|
48
|
+
hint === null ? null : 'search-hint',
|
|
49
|
+
errorMessage === null ? null : 'search-error',
|
|
50
|
+
]
|
|
51
|
+
.filter((id): id is string => id !== null)
|
|
52
|
+
.join(' ')
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Frame aria-labelledby="search-heading">
|
|
56
|
+
<PanelHead id="search-heading" title="Search" />
|
|
57
|
+
|
|
58
|
+
<form method="get" action={action} className="flex flex-col gap-4 px-4 py-4">
|
|
59
|
+
<div>
|
|
60
|
+
<label htmlFor="search-query" className={`${MICRO} mb-1 block`}>
|
|
61
|
+
Search for
|
|
62
|
+
</label>
|
|
63
|
+
<input
|
|
64
|
+
id="search-query"
|
|
65
|
+
type="search"
|
|
66
|
+
name={fields.query}
|
|
67
|
+
defaultValue={query}
|
|
68
|
+
maxLength={maxQueryLength}
|
|
69
|
+
autoComplete="off"
|
|
70
|
+
placeholder="Words in a subject or a post"
|
|
71
|
+
aria-invalid={errorMessage === null ? undefined : true}
|
|
72
|
+
{...(described === '' ? {} : { 'aria-describedby': described })}
|
|
73
|
+
className={CONTROL}
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
{hint !== null && (
|
|
77
|
+
<p id="search-hint" className="mt-1.5 text-xs text-muted-foreground">
|
|
78
|
+
{hint}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{errorMessage !== null && (
|
|
83
|
+
<p id="search-error" className={`${MICRO} mt-1.5 text-destructive`}>
|
|
84
|
+
{errorMessage}
|
|
85
|
+
</p>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
90
|
+
<Choice id="search-forum" label="In" name={fields.forum} options={forums} />
|
|
91
|
+
<Choice id="search-sort" label="Sort by" name={fields.sort} options={sorts} />
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div>
|
|
95
|
+
<button type="submit" className={BUTTON_PRIMARY}>
|
|
96
|
+
search
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</form>
|
|
100
|
+
</Frame>
|
|
101
|
+
)
|
|
102
|
+
}
|