@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.
Files changed (39) hide show
  1. package/LICENSE.md +165 -0
  2. package/package.json +33 -0
  3. package/src/index.ts +3 -0
  4. package/src/shared.tsx +133 -0
  5. package/src/slots/announcement.tsx +40 -0
  6. package/src/slots/auth-page.tsx +53 -0
  7. package/src/slots/board-index.tsx +35 -0
  8. package/src/slots/board-stats.tsx +52 -0
  9. package/src/slots/category-block.tsx +37 -0
  10. package/src/slots/discovery-view.tsx +122 -0
  11. package/src/slots/error-notice.tsx +26 -0
  12. package/src/slots/footer.tsx +34 -0
  13. package/src/slots/forum-display.tsx +81 -0
  14. package/src/slots/forum-jump.tsx +45 -0
  15. package/src/slots/forum-row.tsx +66 -0
  16. package/src/slots/header.tsx +67 -0
  17. package/src/slots/latest-posts.tsx +42 -0
  18. package/src/slots/latest-threads.tsx +42 -0
  19. package/src/slots/member-profile.tsx +102 -0
  20. package/src/slots/navigation.tsx +31 -0
  21. package/src/slots/notice.tsx +32 -0
  22. package/src/slots/pagination.tsx +48 -0
  23. package/src/slots/panel-nav.tsx +110 -0
  24. package/src/slots/panel-page.tsx +72 -0
  25. package/src/slots/panel-shell.tsx +33 -0
  26. package/src/slots/post-actions.tsx +29 -0
  27. package/src/slots/post-bit.tsx +225 -0
  28. package/src/slots/post-form.tsx +44 -0
  29. package/src/slots/redirect-notice.tsx +26 -0
  30. package/src/slots/search-form.tsx +102 -0
  31. package/src/slots/search-results.tsx +108 -0
  32. package/src/slots/shell.tsx +40 -0
  33. package/src/slots/subforum-list.tsx +26 -0
  34. package/src/slots/thread-row.tsx +74 -0
  35. package/src/slots/thread-view.tsx +75 -0
  36. package/src/slots/user-panel.tsx +118 -0
  37. package/src/slots/who-is-online.tsx +64 -0
  38. package/src/theme.ts +84 -0
  39. 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
+ }