@meith/theme-midnight 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.
@@ -0,0 +1,44 @@
1
+ import type { LatestThreadsModel } from '@meith/theme-kit'
2
+
3
+ import { UserRef } from '../shared'
4
+
5
+ export function LatestThreads({ threads, capturedAt }: LatestThreadsModel) {
6
+ return (
7
+ <section aria-labelledby="latest-threads-heading" className="border border-border">
8
+ <h2
9
+ id="latest-threads-heading"
10
+ className="border-b border-border bg-secondary px-3 py-1 font-mono text-xs uppercase tracking-wide"
11
+ >
12
+ Latest threads
13
+ </h2>
14
+
15
+ {threads.length === 0 ? (
16
+ <p className="px-3 py-2 font-mono text-xs text-muted-foreground">nothing started yet</p>
17
+ ) : (
18
+ <ul className="divide-y divide-border font-mono text-xs">
19
+ {threads.map((thread) => (
20
+ <li key={thread.href} className="px-3 py-1.5">
21
+ <a href={thread.href} className="text-primary hover:underline">
22
+ {thread.title}
23
+ </a>
24
+ <span className="text-muted-foreground">
25
+ {' · '}
26
+ <a href={thread.forum.href} className="hover:text-foreground">
27
+ {thread.forum.label}
28
+ </a>
29
+ {' · '}
30
+ <UserRef user={thread.author} className="hover:text-foreground" />
31
+ {' · '}
32
+ <time dateTime={thread.startedAt.iso}>{thread.startedAt.label}</time>
33
+ </span>
34
+ </li>
35
+ ))}
36
+ </ul>
37
+ )}
38
+
39
+ <p className="border-t border-border px-3 py-1 font-mono text-xs text-muted-foreground">
40
+ as of <time dateTime={capturedAt.iso}>{capturedAt.label}</time>
41
+ </p>
42
+ </section>
43
+ )
44
+ }
@@ -0,0 +1,91 @@
1
+ import type { MemberProfileModel } from '@meith/theme-kit'
2
+
3
+ export function MemberProfile({
4
+ user,
5
+ avatarUrl,
6
+ title,
7
+ joinedAt,
8
+ lastVisitAt,
9
+ postCount,
10
+ signatureHtml,
11
+ fields,
12
+ actions,
13
+ regions,
14
+ }: MemberProfileModel) {
15
+ return (
16
+ <article className="flex flex-col gap-3 p-3">
17
+ <header className="flex items-start gap-3 border border-border bg-secondary p-3">
18
+ {avatarUrl !== null && (
19
+ <img
20
+ src={avatarUrl}
21
+ alt=""
22
+ width={72}
23
+ height={72}
24
+ className="size-18 border border-border object-cover"
25
+ />
26
+ )}
27
+ <div>
28
+ <h1
29
+ className={['font-mono text-2xl font-semibold', user.nameClass]
30
+ .filter(Boolean)
31
+ .join(' ')}
32
+ >
33
+ {user.username}
34
+ </h1>
35
+ {title !== null && <p className="font-mono text-xs text-muted-foreground">{title}</p>}
36
+ </div>
37
+ </header>
38
+
39
+ <dl className="grid grid-cols-2 gap-x-4 gap-y-1 border border-border p-3 font-mono text-xs sm:grid-cols-3">
40
+ <div>
41
+ <dt className="text-muted-foreground">joined</dt>
42
+ <dd>
43
+ <time dateTime={joinedAt.iso}>{joinedAt.label}</time>
44
+ </dd>
45
+ </div>
46
+ <div>
47
+ <dt className="text-muted-foreground">last visit</dt>
48
+ <dd>
49
+ {lastVisitAt === null ? (
50
+ 'never'
51
+ ) : (
52
+ <time dateTime={lastVisitAt.iso}>{lastVisitAt.label}</time>
53
+ )}
54
+ </dd>
55
+ </div>
56
+ <div>
57
+ <dt className="text-muted-foreground">posts</dt>
58
+ <dd>{postCount}</dd>
59
+ </div>
60
+ {fields.map((field) => (
61
+ <div key={field.label}>
62
+ <dt className="text-muted-foreground">{field.label}</dt>
63
+ <dd>{field.value}</dd>
64
+ </div>
65
+ ))}
66
+ </dl>
67
+
68
+ {signatureHtml !== null && (
69
+ <section aria-label="Signature" className="border border-border p-3 text-sm">
70
+ <div className="prose-md" dangerouslySetInnerHTML={{ __html: signatureHtml }} />
71
+ </section>
72
+ )}
73
+
74
+ {regions?.plugins}
75
+
76
+ {actions.length > 0 && (
77
+ <nav aria-label="Member actions" className="flex flex-wrap gap-2 font-mono text-xs">
78
+ {actions.map((action) => (
79
+ <a
80
+ key={action.href}
81
+ href={action.href}
82
+ className="border border-border px-2 py-1 hover:bg-muted"
83
+ >
84
+ {action.label}
85
+ </a>
86
+ ))}
87
+ </nav>
88
+ )}
89
+ </article>
90
+ )
91
+ }
@@ -0,0 +1,27 @@
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-muted px-4 py-1.5">
6
+ <ol className="flex flex-wrap items-center gap-1 font-mono text-xs text-muted-foreground">
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">
11
+ {index > 0 && <span aria-hidden="true">/</span>}
12
+ {isLast ? (
13
+ <span aria-current="page" className="text-foreground">
14
+ {item.label}
15
+ </span>
16
+ ) : (
17
+ <a href={item.href} className="hover:text-foreground">
18
+ {item.label}
19
+ </a>
20
+ )}
21
+ </li>
22
+ )
23
+ })}
24
+ </ol>
25
+ </nav>
26
+ )
27
+ }
@@ -0,0 +1,30 @@
1
+ import type { NoticeModel } from '@meith/theme-kit'
2
+
3
+ const EDGE: Record<NoticeModel['kind'], string> = {
4
+ info: 'border-l-primary',
5
+ success: 'border-l-moderation-approved',
6
+ warning: 'border-l-thread-pinned',
7
+ error: 'border-l-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-4 border-border bg-muted px-3 py-2 text-sm ${EDGE[kind]}`}
15
+ >
16
+ <p>
17
+ <span className="font-mono text-xs uppercase tracking-wide text-muted-foreground">
18
+ {kind}
19
+ {': '}
20
+ </span>
21
+ {message}
22
+ </p>
23
+ {dismissHref !== null && (
24
+ <a href={dismissHref} className="font-mono text-xs text-muted-foreground hover:text-foreground">
25
+ dismiss
26
+ </a>
27
+ )}
28
+ </div>
29
+ )
30
+ }
@@ -0,0 +1,38 @@
1
+ import type { PaginationModel } from '@meith/theme-kit'
2
+
3
+ export function Pagination({ page, pageCount, pages, previousHref, nextHref }: PaginationModel) {
4
+ if (pageCount <= 1) return null
5
+
6
+ return (
7
+ <nav aria-label="Pagination" className="flex flex-wrap items-center gap-1 font-mono text-xs">
8
+ {previousHref !== null && (
9
+ <a href={previousHref} rel="prev" className="border border-border px-2 py-1 hover:bg-muted">
10
+ prev
11
+ </a>
12
+ )}
13
+ {pages.map((entry) =>
14
+ entry.isCurrent ? (
15
+ <span
16
+ key={entry.href}
17
+ aria-current="page"
18
+ className="border border-primary bg-primary px-2 py-1 text-primary-foreground"
19
+ >
20
+ {entry.page}
21
+ </span>
22
+ ) : (
23
+ <a key={entry.href} href={entry.href} className="border border-border px-2 py-1 hover:bg-muted">
24
+ {entry.page}
25
+ </a>
26
+ ),
27
+ )}
28
+ {nextHref !== null && (
29
+ <a href={nextHref} rel="next" className="border border-border px-2 py-1 hover:bg-muted">
30
+ next
31
+ </a>
32
+ )}
33
+ <span className="ml-2 text-muted-foreground">
34
+ page {page} of {pageCount}
35
+ </span>
36
+ </nav>
37
+ )
38
+ }
@@ -0,0 +1,26 @@
1
+ import type { PostActionsSlotModel } from '@meith/theme-kit'
2
+
3
+ export function PostActions({ actions, children }: PostActionsSlotModel) {
4
+ const items = [
5
+ { href: actions.quoteHref, label: 'quote' },
6
+ { href: actions.editHref, label: 'edit' },
7
+ { href: actions.restoreHref, label: 'restore' },
8
+ { href: actions.rateHref, label: 'rate' },
9
+ { href: actions.reportHref, label: 'report' },
10
+ { href: actions.warnHref, label: 'warn' },
11
+ { href: actions.moderateHref, label: 'moderate' },
12
+ ].filter((item): item is { href: string; label: string } => item.href !== null)
13
+
14
+ if (items.length === 0 && children === undefined) return null
15
+
16
+ return (
17
+ <nav aria-label="Post actions" className="flex flex-wrap gap-3 font-mono text-xs">
18
+ {items.map((item) => (
19
+ <a key={item.label} href={item.href} className="text-muted-foreground hover:text-primary">
20
+ {item.label}
21
+ </a>
22
+ ))}
23
+ {children}
24
+ </nav>
25
+ )
26
+ }
@@ -0,0 +1,182 @@
1
+ import type { PostBitSlotModel } from '@meith/theme-kit'
2
+
3
+ import { UserRef } from '../shared'
4
+
5
+ function GroupBadge({ badge }: { badge: NonNullable<PostBitSlotModel['post']['author']['badge']> }) {
6
+ const image = (
7
+ <img
8
+ src={badge.src}
9
+ alt=""
10
+ aria-hidden="true"
11
+ className="h-4 w-auto max-w-full object-contain"
12
+ loading="lazy"
13
+ decoding="async"
14
+ />
15
+ )
16
+
17
+ if (badge.darkSrc === null) return image
18
+
19
+ return (
20
+ <picture>
21
+ <source media="(prefers-color-scheme: dark)" srcSet={badge.darkSrc} />
22
+ {image}
23
+ </picture>
24
+ )
25
+ }
26
+
27
+ function StatusBanner({ visibility }: { visibility: PostBitSlotModel['post']['visibility'] }) {
28
+ if (visibility === 'visible') return null
29
+ return (
30
+ <p className="border-b border-border bg-muted px-3 py-1 font-mono text-xs text-muted-foreground">
31
+ {visibility === 'deleted'
32
+ ? 'deleted — visible to moderators only'
33
+ : 'awaiting approval — visible to moderators only'}
34
+ </p>
35
+ )
36
+ }
37
+
38
+ export function PostBit({ post, select, regions }: PostBitSlotModel) {
39
+ return (
40
+ <article id={`post-${post.number}`} data-post-id={post.id} className="border border-border">
41
+ <StatusBanner visibility={post.visibility} />
42
+
43
+ <div className="grid grid-cols-1 sm:grid-cols-[11rem_1fr]">
44
+ <div className="border-b border-border bg-secondary px-3 py-2 sm:border-b-0 sm:border-r">
45
+ <p className="flex items-center gap-2">
46
+ {select !== null && (
47
+ <label className="flex items-center">
48
+ <span className="sr-only">{select.label}</span>
49
+ <input
50
+ type="checkbox"
51
+ name={select.name}
52
+ value={select.value}
53
+ form={select.formId}
54
+ className="size-3.5"
55
+ />
56
+ </label>
57
+ )}
58
+ <UserRef
59
+ user={post.author}
60
+ className={
61
+ post.author.profileHref === null ? 'font-medium' : 'font-medium hover:text-primary'
62
+ }
63
+ />
64
+ </p>
65
+
66
+ {regions.pluginBadges}
67
+
68
+ {post.author.badge != null && (
69
+ <p className="mt-1">
70
+ <GroupBadge badge={post.author.badge} />
71
+ </p>
72
+ )}
73
+ {post.author.title !== null && (
74
+ <p className="font-mono text-xs text-muted-foreground">{post.author.title}</p>
75
+ )}
76
+ {post.author.avatarUrl !== null && (
77
+ <img
78
+ src={post.author.avatarUrl}
79
+ alt=""
80
+ width={64}
81
+ height={64}
82
+ className="mt-2 size-16 border border-border object-cover"
83
+ />
84
+ )}
85
+ <dl className="mt-2 font-mono text-xs text-muted-foreground">
86
+ <div className="flex gap-1">
87
+ <dt>posts</dt>
88
+ <dd className="text-foreground">{post.author.postCount}</dd>
89
+ </div>
90
+ {post.author.joinedAt !== null && (
91
+ <div className="flex gap-1">
92
+ <dt>joined</dt>
93
+ <dd>
94
+ <time dateTime={post.author.joinedAt.iso}>{post.author.joinedAt.label}</time>
95
+ </dd>
96
+ </div>
97
+ )}
98
+ {post.author.reputation != null && (
99
+ <div className="flex gap-1">
100
+ <dt>rep</dt>
101
+ <dd className="text-foreground">{post.author.reputation}</dd>
102
+ </div>
103
+ )}
104
+ {post.author.isOnline && <div className="text-forum-unread">online</div>}
105
+ </dl>
106
+
107
+ {post.author.fields.length > 0 && (
108
+ <dl className="mt-2 font-mono text-xs text-muted-foreground">
109
+ {post.author.fields.map((field) => (
110
+ <div key={field.label} className="flex gap-1">
111
+ <dt>{field.label}</dt>
112
+ <dd className="text-foreground">{field.value}</dd>
113
+ </div>
114
+ ))}
115
+ </dl>
116
+ )}
117
+ </div>
118
+
119
+ <div className="min-w-0">
120
+ <div className="flex items-baseline justify-between gap-2 border-b border-border px-3 py-1 font-mono text-xs text-muted-foreground">
121
+ <time dateTime={post.postedAt.iso}>{post.postedAt.label}</time>
122
+ <a href={post.permalink} className="hover:text-foreground">
123
+ #{post.number}
124
+ </a>
125
+ </div>
126
+
127
+ {post.ignored !== null ? (
128
+ <p className="px-3 py-3 text-sm text-muted-foreground">
129
+ Post from {post.ignored.authorUsername}, who you are ignoring.{' '}
130
+ <a href={post.ignored.revealHref} className="text-primary hover:underline">
131
+ Show it
132
+ </a>
133
+ </p>
134
+ ) : (
135
+ <>
136
+ <div
137
+ className="prose-md px-3 py-3 text-sm"
138
+ dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
139
+ />
140
+
141
+ {post.attachments.length > 0 && (
142
+ <ul className="border-t border-border px-3 py-2 font-mono text-xs">
143
+ {post.attachments.map((attachment) => (
144
+ <li key={attachment.id}>
145
+ <a href={attachment.href} className="text-primary hover:underline">
146
+ {attachment.filename}
147
+ </a>
148
+ <span className="ml-2 text-muted-foreground">{attachment.size}</span>
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ )}
153
+
154
+ {post.author.signatureHtml !== null && (
155
+ <div
156
+ className="border-t border-border px-3 py-2 text-xs text-muted-foreground"
157
+ dangerouslySetInnerHTML={{ __html: post.author.signatureHtml }}
158
+ />
159
+ )}
160
+ </>
161
+ )}
162
+
163
+ {post.editedNote !== null && (
164
+ <p className="border-t border-border px-3 py-1 font-mono text-xs text-muted-foreground">
165
+ {post.editedNote}
166
+ </p>
167
+ )}
168
+
169
+ {regions.pluginFooter !== undefined && regions.pluginFooter !== null && (
170
+ <div className="border-t border-border px-3 py-1 font-mono text-xs">
171
+ {regions.pluginFooter}
172
+ </div>
173
+ )}
174
+
175
+ {regions.actions !== null && (
176
+ <div className="border-t border-border px-3 py-1">{regions.actions}</div>
177
+ )}
178
+ </div>
179
+ </div>
180
+ </article>
181
+ )
182
+ }
@@ -0,0 +1,20 @@
1
+ import type { ShellModel } from '@meith/theme-kit'
2
+
3
+ export function Shell({ viewer, children }: ShellModel) {
4
+ return (
5
+ <div
6
+ className="min-h-dvh bg-background text-foreground"
7
+ data-viewer={viewer.isGuest ? 'guest' : 'member'}
8
+ >
9
+ <a
10
+ href="#board-content"
11
+ className="sr-only focus:not-sr-only focus:absolute focus:left-2 focus:top-2 focus:z-50 focus:border focus:border-primary focus:bg-card focus:px-3 focus:py-2 focus:text-sm"
12
+ >
13
+ Skip to content
14
+ </a>
15
+ <div className="mx-auto flex min-h-dvh w-full max-w-6xl flex-col border-x border-border bg-card">
16
+ {children}
17
+ </div>
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,16 @@
1
+ import type { SubforumListModel } from '@meith/theme-kit'
2
+
3
+ export function SubforumList({ forums }: SubforumListModel) {
4
+ if (forums.length === 0) return null
5
+
6
+ return (
7
+ <nav aria-label="Subforums" className="flex flex-wrap gap-x-3 gap-y-1 border border-border bg-muted px-3 py-2 font-mono text-xs">
8
+ {forums.map((forum) => (
9
+ <a key={forum.href} href={forum.href} className="text-primary hover:underline">
10
+ {forum.title}
11
+ <span className="ml-1 text-muted-foreground">({forum.threadCount})</span>
12
+ </a>
13
+ ))}
14
+ </nav>
15
+ )
16
+ }
@@ -0,0 +1,62 @@
1
+ import type { ThreadRowSlotModel } from '@meith/theme-kit'
2
+
3
+ import { UserRef } from '../shared'
4
+
5
+ export function ThreadRow({ thread, select }: ThreadRowSlotModel) {
6
+ return (
7
+ <tr className="border-t border-border align-top odd:bg-card even:bg-muted/40">
8
+ <td className="px-3 py-2">
9
+ <div className="flex flex-wrap items-baseline gap-2">
10
+ {select !== null && (
11
+ <label className="flex items-center">
12
+ <span className="sr-only">{select.label}</span>
13
+ <input
14
+ type="checkbox"
15
+ name={select.name}
16
+ value={select.value}
17
+ form={select.formId}
18
+ className="size-3.5"
19
+ />
20
+ </label>
21
+ )}
22
+ {thread.isUnread && (
23
+ <span className="size-1.5 shrink-0 bg-forum-unread" aria-hidden="true" />
24
+ )}
25
+ {thread.prefix !== null && (
26
+ <span className="border border-border px-1 font-mono text-xs text-secondary-foreground">
27
+ {thread.prefix.label}
28
+ </span>
29
+ )}
30
+ <a href={thread.href} className="font-medium hover:text-primary">
31
+ {thread.title}
32
+ </a>
33
+ {thread.isUnread && <span className="sr-only">(new posts)</span>}
34
+ {thread.isSticky && <span className="font-mono text-xs text-thread-pinned">pinned</span>}
35
+ {thread.isLocked && <span className="font-mono text-xs text-thread-locked">locked</span>}
36
+ {thread.isMoved && <span className="font-mono text-xs text-thread-moved">moved</span>}
37
+ </div>
38
+ <p className="mt-0.5 font-mono text-xs text-muted-foreground">
39
+ <UserRef user={thread.author} className="hover:text-foreground" />
40
+ </p>
41
+ </td>
42
+ <td className="w-16 px-2 py-2 text-right font-mono text-xs text-muted-foreground">
43
+ {thread.replyCount}
44
+ </td>
45
+ <td className="w-16 px-2 py-2 text-right font-mono text-xs text-muted-foreground">
46
+ {thread.viewCount}
47
+ </td>
48
+ <td className="w-56 px-3 py-2 text-xs text-muted-foreground">
49
+ {thread.lastPost === null ? (
50
+ <span className="text-forum-read">—</span>
51
+ ) : (
52
+ <a href={thread.lastPost.href} className="hover:text-primary">
53
+ <time dateTime={thread.lastPost.at.iso}>{thread.lastPost.at.label}</time>
54
+ <span className="block">
55
+ by <UserRef user={thread.lastPost.author} linked={false} />
56
+ </span>
57
+ </a>
58
+ )}
59
+ </td>
60
+ </tr>
61
+ )
62
+ }
@@ -0,0 +1,55 @@
1
+ import type { ThreadViewModel } from '@meith/theme-kit'
2
+
3
+ export function ThreadView({ thread, forum, replyHref, markReadAction, regions }: ThreadViewModel) {
4
+ const reply =
5
+ replyHref === null ? null : (
6
+ <a
7
+ href={replyHref}
8
+ className="border border-primary bg-primary px-3 py-1 font-mono text-xs text-primary-foreground hover:opacity-90"
9
+ >
10
+ reply
11
+ </a>
12
+ )
13
+
14
+ return (
15
+ <div className="flex flex-col gap-3 p-3">
16
+ <div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-border pb-2">
17
+ <div>
18
+ <h1 className="font-mono text-lg font-semibold">{thread.title}</h1>
19
+ <p className="font-mono text-xs text-muted-foreground">
20
+ in <a href={forum.href} className="hover:text-primary">{forum.label}</a>
21
+ </p>
22
+ </div>
23
+ {reply}
24
+ </div>
25
+
26
+ {regions.tools !== undefined && (
27
+ <div className="flex flex-col gap-2 empty:hidden">{regions.tools}</div>
28
+ )}
29
+
30
+ {regions.pagination}
31
+ <div className="flex flex-col gap-2">{regions.posts}</div>
32
+ {regions.pagination}
33
+
34
+ {regions.afterContent !== undefined && (
35
+ <div className="flex flex-col gap-2 empty:hidden">{regions.afterContent}</div>
36
+ )}
37
+
38
+ {regions.quickReply}
39
+
40
+ <div className="flex flex-wrap items-center gap-2">
41
+ {reply}
42
+ {markReadAction !== null && (
43
+ <form action={markReadAction} method="post">
44
+ <button
45
+ type="submit"
46
+ className="border border-border px-2 py-1 font-mono text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
47
+ >
48
+ mark read
49
+ </button>
50
+ </form>
51
+ )}
52
+ </div>
53
+ </div>
54
+ )
55
+ }
@@ -0,0 +1,32 @@
1
+ import type { UserPanelModel } from '@meith/theme-kit'
2
+
3
+ export function UserPanel({
4
+ viewer,
5
+ links,
6
+ unreadNotifications,
7
+ unreadMessages,
8
+ children,
9
+ }: UserPanelModel) {
10
+ return (
11
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs">
12
+ <span className="text-muted-foreground">
13
+ {viewer.isGuest ? 'guest' : (viewer.username ?? 'signed in')}
14
+ </span>
15
+
16
+ {unreadNotifications > 0 && (
17
+ <span className="text-accent">[{unreadNotifications} new]</span>
18
+ )}
19
+ {unreadMessages > 0 && (
20
+ <span className="text-accent">[{unreadMessages} pm]</span>
21
+ )}
22
+
23
+ {links.map((link) => (
24
+ <a key={link.href} href={link.href} className="text-primary hover:underline">
25
+ {link.label}
26
+ </a>
27
+ ))}
28
+
29
+ {children}
30
+ </div>
31
+ )
32
+ }