@meith/theme-default 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,46 @@
1
+ import { Label, NativeSelect, buttonVariants } from '@meith/ui'
2
+ import type { ForumJumpModel } from '@meith/theme-kit'
3
+
4
+ import { PAGE } from '../shared'
5
+
6
+ export function ForumJump({ action, field, forums, submitLabel, label }: ForumJumpModel) {
7
+ if (forums.length === 0) return null
8
+
9
+ const id = `forum-jump-${field}`
10
+
11
+ return (
12
+ <div className="border-t border-border bg-card">
13
+ <form
14
+ method="get"
15
+ action={action}
16
+ className={`${PAGE} flex flex-wrap items-center gap-2 py-3 sm:justify-end`}
17
+ >
18
+ <Label htmlFor={id} className="text-xs text-muted-foreground">
19
+ {label}
20
+ </Label>
21
+
22
+ <NativeSelect
23
+ id={id}
24
+ name={field}
25
+ defaultValue={forums.find((forum) => forum.isSelected)?.value ?? ''}
26
+ className="h-8 w-auto min-w-48 flex-1 text-xs sm:flex-none"
27
+ >
28
+ {forums.map((forum) => (
29
+ <option
30
+ key={forum.value}
31
+ value={forum.value}
32
+ disabled={forum.isCategory}
33
+ label={`${' '.repeat(forum.depth)}${forum.label}`}
34
+ >
35
+ {`${' '.repeat(forum.depth)}${forum.label}`}
36
+ </option>
37
+ ))}
38
+ </NativeSelect>
39
+
40
+ <button type="submit" className={buttonVariants({ variant: 'outline', size: 'sm' })}>
41
+ {submitLabel}
42
+ </button>
43
+ </form>
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,90 @@
1
+ import type { ForumRowSlotModel } from '@meith/theme-kit'
2
+
3
+ import { Counts, LINK, MUTED_LINK, ReadSpacer, Stamp, UnreadDot, UserRef } from '../shared'
4
+
5
+ export function ForumRow({ forum }: ForumRowSlotModel) {
6
+ const isLink = forum.type === 'link'
7
+
8
+ return (
9
+ <li
10
+ data-unread={forum.isUnread ? '' : undefined}
11
+ className={
12
+ 'grid grid-cols-[auto_minmax(0,1fr)] gap-x-2.5 gap-y-2 px-4 py-3 transition-colors hover:bg-muted/60' +
13
+ (isLink ? '' : ' md:grid-cols-[auto_minmax(0,1fr)_9rem_15rem] md:items-center md:gap-x-4')
14
+ }
15
+ >
16
+ {forum.isUnread ? <UnreadDot /> : <ReadSpacer />}
17
+
18
+ <div className="min-w-0">
19
+ <a
20
+ href={forum.href}
21
+ className={
22
+ (forum.isUnread ? 'font-semibold text-foreground' : 'font-medium text-foreground') +
23
+ ` ${LINK}`
24
+ }
25
+ >
26
+ {forum.title}
27
+ </a>
28
+ {forum.isUnread && <span className="sr-only"> (new posts)</span>}
29
+
30
+ {forum.description !== null && (
31
+ <p className="mt-0.5 text-sm text-muted-foreground">{forum.description}</p>
32
+ )}
33
+
34
+ {forum.subforums.length > 0 && (
35
+ <p className="mt-1.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
36
+ <span className="font-medium text-foreground">Subforums</span>
37
+ {forum.subforums.map((sub) => (
38
+ <a key={sub.href} href={sub.href} className={MUTED_LINK}>
39
+ {sub.label}
40
+ </a>
41
+ ))}
42
+ </p>
43
+ )}
44
+ </div>
45
+
46
+ {!isLink && (
47
+ <>
48
+ <Counts
49
+ className="col-start-2 md:col-start-3 md:justify-end"
50
+ items={[
51
+ {
52
+ label: 'Threads',
53
+ value: forum.threadCount,
54
+ one: 'thread',
55
+ many: 'threads',
56
+ },
57
+ {
58
+ label: 'Posts',
59
+ value: forum.postCount,
60
+ one: 'post',
61
+ many: 'posts',
62
+ },
63
+ ]}
64
+ />
65
+
66
+ <div className="col-start-2 min-w-0 text-xs text-muted-foreground md:col-start-4">
67
+ {forum.lastPost === null ? (
68
+ <span className="text-forum-read">No posts yet</span>
69
+ ) : (
70
+ <>
71
+ <a
72
+ href={forum.lastPost.href}
73
+ className={`block truncate font-medium text-foreground ${LINK}`}
74
+ >
75
+ {forum.lastPost.threadTitle}
76
+ </a>
77
+ <span className="mt-0.5 block truncate">
78
+ {'by '}
79
+ <UserRef user={forum.lastPost.author} className="font-normal" />
80
+ {' · '}
81
+ <Stamp at={forum.lastPost.at} />
82
+ </span>
83
+ </>
84
+ )}
85
+ </div>
86
+ </>
87
+ )}
88
+ </li>
89
+ )
90
+ }
@@ -0,0 +1,60 @@
1
+ import type { HeaderModel } from '@meith/theme-kit'
2
+
3
+ import { LINK, PAGE } from '../shared'
4
+
5
+ function BoardMark({ boardTitle, logo }: Pick<HeaderModel, 'boardTitle' | 'logo'>) {
6
+ if (logo === undefined) return <>{boardTitle}</>
7
+
8
+ const image = (
9
+ <img
10
+ src={logo.src}
11
+ alt={logo.alt}
12
+ className="h-8 w-auto max-w-48 object-contain"
13
+ decoding="async"
14
+ />
15
+ )
16
+
17
+ if (logo.darkSrc === null) return image
18
+
19
+ return (
20
+ <picture>
21
+ <source media="(prefers-color-scheme: dark)" srcSet={logo.darkSrc} />
22
+ {image}
23
+ </picture>
24
+ )
25
+ }
26
+
27
+ export function Header({ boardTitle, homeHref, navigation, logo, children }: HeaderModel) {
28
+ return (
29
+ <header className="border-b border-border bg-card">
30
+ <div className={`${PAGE} flex flex-wrap items-center justify-between gap-x-6 gap-y-3 py-3`}>
31
+ <a
32
+ href={homeHref}
33
+ className={`inline-flex items-center text-xl font-semibold tracking-tight text-foreground ${LINK}`}
34
+ >
35
+ <BoardMark boardTitle={boardTitle} logo={logo} />
36
+ </a>
37
+ {children}
38
+ </div>
39
+
40
+ {navigation.length > 0 && (
41
+ <nav aria-label="Board sections" className="border-t border-border bg-surface">
42
+ <div className={PAGE}>
43
+ <ul className="-mx-4 flex items-stretch gap-1 overflow-x-auto px-4 text-sm sm:-mx-6 sm:px-6">
44
+ {navigation.map((item) => (
45
+ <li key={item.href} className="shrink-0">
46
+ <a
47
+ href={item.href}
48
+ className="inline-flex h-10 items-center rounded-t-md border-b-2 border-transparent px-2.5 font-medium text-muted-foreground transition-colors hover:border-border hover:text-foreground"
49
+ >
50
+ {item.label}
51
+ </a>
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ </div>
56
+ </nav>
57
+ )}
58
+ </header>
59
+ )
60
+ }
@@ -0,0 +1,53 @@
1
+ import { Card, CardContent, CardHeader, CardTitle, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
2
+ import type { LatestPostsModel } from '@meith/theme-kit'
3
+
4
+ import { LINK, MUTED_LINK, NUMERIC, Stamp, UserRef } from '../shared'
5
+
6
+ export function LatestPosts({ posts, capturedAt }: LatestPostsModel) {
7
+ return (
8
+ <Card aria-labelledby="latest-posts-heading">
9
+ <CardHeader>
10
+ <CardTitle id="latest-posts-heading">Latest posts</CardTitle>
11
+ <p className={`text-xs text-muted-foreground ${NUMERIC}`}>
12
+ As of <Stamp at={capturedAt} />
13
+ </p>
14
+ </CardHeader>
15
+
16
+ {posts.length === 0 ? (
17
+ <Empty className="py-6">
18
+ <EmptyTitle>Nothing said yet</EmptyTitle>
19
+ <EmptyDescription>The newest posts you can see will appear here.</EmptyDescription>
20
+ </Empty>
21
+ ) : (
22
+ <CardContent className="px-0 py-0">
23
+ <ul className="divide-y divide-border">
24
+ {posts.map((post) => (
25
+ <li key={post.href} className="flex flex-col gap-0.5 px-4 py-2">
26
+ <a
27
+ href={post.href}
28
+ className={`truncate text-sm font-medium text-foreground ${LINK}`}
29
+ >
30
+ {post.threadTitle}
31
+ </a>
32
+
33
+ {post.excerpt !== '' && (
34
+ <p className="line-clamp-2 text-xs text-muted-foreground">{post.excerpt}</p>
35
+ )}
36
+
37
+ <p className="truncate text-xs text-muted-foreground">
38
+ <UserRef user={post.author} className="font-normal" />
39
+ {' in '}
40
+ <a href={post.forum.href} className={MUTED_LINK}>
41
+ {post.forum.label}
42
+ </a>
43
+ {' · '}
44
+ <Stamp at={post.postedAt} />
45
+ </p>
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ </CardContent>
50
+ )}
51
+ </Card>
52
+ )
53
+ }
@@ -0,0 +1,57 @@
1
+ import { Card, CardContent, CardHeader, CardTitle, Empty, EmptyDescription, EmptyTitle } from '@meith/ui'
2
+ import type { LatestThreadsModel } from '@meith/theme-kit'
3
+
4
+ import { LINK, MUTED_LINK, NUMERIC, Stamp, UserRef } from '../shared'
5
+
6
+ export function LatestThreads({ threads, capturedAt }: LatestThreadsModel) {
7
+ return (
8
+ <Card aria-labelledby="latest-threads-heading">
9
+ <CardHeader>
10
+ <CardTitle id="latest-threads-heading">Latest threads</CardTitle>
11
+ <p className={`text-xs text-muted-foreground ${NUMERIC}`}>
12
+ As of <Stamp at={capturedAt} />
13
+ </p>
14
+ </CardHeader>
15
+
16
+ {threads.length === 0 ? (
17
+ <Empty className="py-6">
18
+ <EmptyTitle>Nothing started yet</EmptyTitle>
19
+ <EmptyDescription>
20
+ The newest threads you can see will appear here.
21
+ </EmptyDescription>
22
+ </Empty>
23
+ ) : (
24
+ <CardContent className="px-0 py-0">
25
+ <ul className="divide-y divide-border">
26
+ {threads.map((thread) => (
27
+ <li key={thread.href} className="flex flex-col gap-0.5 px-4 py-2">
28
+ <div className="flex items-baseline justify-between gap-2">
29
+ <a
30
+ href={thread.href}
31
+ className={`truncate text-sm font-medium text-foreground ${LINK}`}
32
+ >
33
+ {thread.title}
34
+ </a>
35
+ <span className={`shrink-0 text-xs text-muted-foreground ${NUMERIC}`}>
36
+ {thread.replyCount.toLocaleString('en')}{' '}
37
+ {thread.replyCount === 1 ? 'reply' : 'replies'}
38
+ </span>
39
+ </div>
40
+
41
+ <p className="truncate text-xs text-muted-foreground">
42
+ <UserRef user={thread.author} className="font-normal" />
43
+ {' in '}
44
+ <a href={thread.forum.href} className={MUTED_LINK}>
45
+ {thread.forum.label}
46
+ </a>
47
+ {' · '}
48
+ <Stamp at={thread.startedAt} />
49
+ </p>
50
+ </li>
51
+ ))}
52
+ </ul>
53
+ </CardContent>
54
+ )}
55
+ </Card>
56
+ )
57
+ }
@@ -0,0 +1,117 @@
1
+ import { Avatar, Card, CardContent, CardHeader, CardTitle, buttonVariants, cn } from '@meith/ui'
2
+ import type { MemberProfileModel } from '@meith/theme-kit'
3
+
4
+ import { NUMERIC, Stamp, pageAt } from '../shared'
5
+
6
+ export function MemberProfile({
7
+ user,
8
+ avatarUrl,
9
+ title,
10
+ joinedAt,
11
+ lastVisitAt,
12
+ postCount,
13
+ signatureHtml,
14
+ fields,
15
+ actions,
16
+ regions,
17
+ }: MemberProfileModel) {
18
+ return (
19
+ <div className={`${pageAt('max-w-4xl')} flex w-full flex-col gap-4 py-6 sm:py-8`}>
20
+ <Card>
21
+ <CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between">
22
+ <div className="flex min-w-0 items-center gap-4">
23
+ <Avatar src={avatarUrl} name={user.username} size={72} />
24
+
25
+ <div className="min-w-0">
26
+ <h1
27
+ className={cn(
28
+ 'text-2xl font-semibold tracking-tight break-words',
29
+ user.nameClass,
30
+ )}
31
+ >
32
+ {user.username}
33
+ </h1>
34
+ {title !== null && <p className="mt-0.5 text-sm text-muted-foreground">{title}</p>}
35
+ </div>
36
+ </div>
37
+
38
+ {actions.length > 0 && (
39
+ <nav aria-label="Member actions" className="flex shrink-0 flex-wrap items-center gap-2">
40
+ {actions.map((action, index) => (
41
+ <a
42
+ key={action.href}
43
+ href={action.href}
44
+ className={buttonVariants({
45
+ variant: index === 0 ? 'primary' : 'outline',
46
+ size: 'sm',
47
+ })}
48
+ >
49
+ {action.label}
50
+ </a>
51
+ ))}
52
+ </nav>
53
+ )}
54
+ </CardContent>
55
+
56
+ <dl className="grid grid-cols-3 gap-3 border-t border-border px-5 py-4">
57
+ <div>
58
+ <dd className={`text-lg font-semibold text-foreground ${NUMERIC}`}>
59
+ {postCount.toLocaleString('en')}
60
+ </dd>
61
+ <dt className="text-xs text-muted-foreground">Posts</dt>
62
+ </div>
63
+ <div>
64
+ <dd className="text-sm font-medium text-foreground">
65
+ <Stamp at={joinedAt} />
66
+ </dd>
67
+ <dt className="text-xs text-muted-foreground">Joined</dt>
68
+ </div>
69
+ <div>
70
+ <dd className="text-sm font-medium text-foreground">
71
+ {lastVisitAt === null ? 'Never' : <Stamp at={lastVisitAt} />}
72
+ </dd>
73
+ <dt className="text-xs text-muted-foreground">Last visit</dt>
74
+ </div>
75
+ </dl>
76
+ </Card>
77
+
78
+ {fields.length > 0 && (
79
+ <Card aria-labelledby="profile-fields-heading">
80
+ <CardHeader>
81
+ <CardTitle id="profile-fields-heading" className="text-sm">
82
+ About
83
+ </CardTitle>
84
+ </CardHeader>
85
+ <CardContent>
86
+ <dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
87
+ {fields.map((field) => (
88
+ <div key={field.label}>
89
+ <dt className="text-xs text-muted-foreground">{field.label}</dt>
90
+ <dd className="text-sm break-words">{field.value}</dd>
91
+ </div>
92
+ ))}
93
+ </dl>
94
+ </CardContent>
95
+ </Card>
96
+ )}
97
+
98
+ {signatureHtml !== null && (
99
+ <Card aria-labelledby="profile-signature-heading">
100
+ <CardHeader>
101
+ <CardTitle id="profile-signature-heading" className="text-sm">
102
+ Signature
103
+ </CardTitle>
104
+ </CardHeader>
105
+ <CardContent>
106
+ <div
107
+ className="prose-md text-sm text-muted-foreground"
108
+ dangerouslySetInnerHTML={{ __html: signatureHtml }}
109
+ />
110
+ </CardContent>
111
+ </Card>
112
+ )}
113
+
114
+ {regions?.plugins}
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,40 @@
1
+ import type { NavigationModel } from '@meith/theme-kit'
2
+
3
+ import { MUTED_LINK, PAGE } from '../shared'
4
+
5
+ export function Navigation({ items }: NavigationModel) {
6
+ if (items.length === 0) return null
7
+
8
+ return (
9
+ <nav aria-label="Breadcrumb" className="border-b border-border bg-card/40">
10
+ <ol
11
+ className={`${PAGE} flex items-center gap-1.5 overflow-x-auto py-2 text-xs whitespace-nowrap text-muted-foreground`}
12
+ >
13
+ {items.map((item, index) => {
14
+ const isLast = index === items.length - 1
15
+ return (
16
+ <li key={item.href} className="flex shrink-0 items-center gap-1.5">
17
+ {index > 0 && (
18
+ <span aria-hidden="true" className="text-border select-none">
19
+ /
20
+ </span>
21
+ )}
22
+ {isLast ? (
23
+ <span
24
+ aria-current="page"
25
+ className="max-w-[24ch] truncate font-medium text-foreground sm:max-w-none"
26
+ >
27
+ {item.label}
28
+ </span>
29
+ ) : (
30
+ <a href={item.href} className={MUTED_LINK}>
31
+ {item.label}
32
+ </a>
33
+ )}
34
+ </li>
35
+ )
36
+ })}
37
+ </ol>
38
+ </nav>
39
+ )
40
+ }
@@ -0,0 +1,27 @@
1
+ import { Alert, AlertDescription, AlertTitle } from '@meith/ui'
2
+ import type { NoticeModel } from '@meith/theme-kit'
3
+
4
+ import { MUTED_LINK } from '../shared'
5
+
6
+ const KIND_LABELS: Record<NoticeModel['kind'], string> = {
7
+ info: 'Notice:',
8
+ success: 'Done:',
9
+ warning: 'Warning:',
10
+ error: 'Error:',
11
+ }
12
+
13
+ export function Notice({ kind, message, dismissHref }: NoticeModel) {
14
+ return (
15
+ <Alert tone={kind}>
16
+ <AlertDescription>
17
+ <AlertTitle>{KIND_LABELS[kind]}</AlertTitle> {message}
18
+ </AlertDescription>
19
+
20
+ {dismissHref !== null && (
21
+ <a href={dismissHref} className={`shrink-0 text-xs ${MUTED_LINK}`}>
22
+ Dismiss
23
+ </a>
24
+ )}
25
+ </Alert>
26
+ )
27
+ }
@@ -0,0 +1,75 @@
1
+ import { buttonVariants, cn } from '@meith/ui'
2
+ import type { PaginationModel } from '@meith/theme-kit'
3
+
4
+ import { NUMERIC } from '../shared'
5
+
6
+ export function Pagination({ page, pageCount, pages, previousHref, nextHref }: PaginationModel) {
7
+ if (pageCount <= 1 && previousHref === null && nextHref === null) return null
8
+
9
+ const step = cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'min-w-20')
10
+ const stepDisabled = cn(step, 'pointer-events-none opacity-40')
11
+
12
+ return (
13
+ <nav aria-label="Pagination" className="flex flex-wrap items-center justify-between gap-3">
14
+ {previousHref === null ? (
15
+ <span className={stepDisabled} aria-hidden="true">
16
+ Previous
17
+ </span>
18
+ ) : (
19
+ <a href={previousHref} rel="prev" className={step}>
20
+ Previous
21
+ </a>
22
+ )}
23
+
24
+ <ol className="flex items-center gap-1">
25
+ {pages.map((entry, index) => {
26
+ const previous = pages[index - 1]
27
+ const gap = previous !== undefined && entry.page - previous.page > 1
28
+
29
+ return (
30
+ <li key={entry.page} className="flex items-center gap-1">
31
+ {gap && (
32
+ <span aria-hidden="true" className="px-1 text-sm text-muted-foreground">
33
+
34
+ </span>
35
+ )}
36
+ <a
37
+ href={entry.href}
38
+ aria-current={entry.isCurrent ? 'page' : undefined}
39
+ aria-label={`Page ${entry.page}`}
40
+ className={cn(
41
+ buttonVariants({
42
+ variant: entry.isCurrent ? 'primary' : 'ghost',
43
+ size: 'sm',
44
+ }),
45
+ 'min-w-8 px-2',
46
+ NUMERIC,
47
+ entry.isCurrent ? '' : 'hidden sm:inline-flex',
48
+ )}
49
+ >
50
+ {entry.page}
51
+ </a>
52
+ </li>
53
+ )
54
+ })}
55
+
56
+ <li className="ml-1 text-xs whitespace-nowrap text-muted-foreground">
57
+ <span className="sr-only">Page </span>
58
+ <span className={NUMERIC}>
59
+ {page} of {pageCount}
60
+ </span>
61
+ </li>
62
+ </ol>
63
+
64
+ {nextHref === null ? (
65
+ <span className={stepDisabled} aria-hidden="true">
66
+ Next
67
+ </span>
68
+ ) : (
69
+ <a href={nextHref} rel="next" className={step}>
70
+ Next
71
+ </a>
72
+ )}
73
+ </nav>
74
+ )
75
+ }
@@ -0,0 +1,54 @@
1
+ import { Separator, buttonVariants } from '@meith/ui'
2
+ import type { PostActionsSlotModel } from '@meith/theme-kit'
3
+
4
+ interface Action {
5
+ readonly href: string
6
+ readonly label: string
7
+ }
8
+
9
+ export function PostActions({ actions, children }: PostActionsSlotModel) {
10
+ const reader: Action[] = [
11
+ actions.quoteHref === null ? null : { href: actions.quoteHref, label: 'Quote' },
12
+ actions.editHref === null ? null : { href: actions.editHref, label: 'Edit' },
13
+ actions.rateHref === null ? null : { href: actions.rateHref, label: 'Rate' },
14
+ actions.reportHref === null ? null : { href: actions.reportHref, label: 'Report' },
15
+ ].filter((action): action is Action => action !== null)
16
+
17
+ const staff: Action[] = [
18
+ actions.restoreHref === null ? null : { href: actions.restoreHref, label: 'Restore' },
19
+ actions.warnHref === null ? null : { href: actions.warnHref, label: 'Warn' },
20
+ actions.moderateHref === null ? null : { href: actions.moderateHref, label: 'Moderate' },
21
+ ].filter((action): action is Action => action !== null)
22
+
23
+ if (reader.length === 0 && staff.length === 0 && children === undefined) return null
24
+
25
+ return (
26
+ <nav aria-label="Post actions" className="flex flex-wrap items-center gap-x-1 gap-y-1">
27
+ {reader.map((action) => (
28
+ <a
29
+ key={action.href}
30
+ href={action.href}
31
+ className={buttonVariants({ variant: 'ghost', size: 'sm' })}
32
+ >
33
+ {action.label}
34
+ </a>
35
+ ))}
36
+
37
+ {reader.length > 0 && staff.length > 0 && (
38
+ <Separator orientation="vertical" className="mx-1.5" />
39
+ )}
40
+
41
+ {staff.map((action) => (
42
+ <a
43
+ key={action.href}
44
+ href={action.href}
45
+ className={buttonVariants({ variant: 'ghost', size: 'sm' })}
46
+ >
47
+ {action.label}
48
+ </a>
49
+ ))}
50
+
51
+ {children !== undefined && <span className="ms-auto empty:hidden">{children}</span>}
52
+ </nav>
53
+ )
54
+ }