@meith/theme-default 0.1.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/theme-default",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Meith's default theme — every slot and every token, and the reference for what a complete theme fills.",
5
5
  "license": "LGPL-3.0-or-later",
6
6
  "repository": {
@@ -20,13 +20,13 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/theme-kit": "^0.1.0",
24
- "@meith/ui": "^0.1.0"
23
+ "@meith/theme-kit": "^0.3.0",
24
+ "@meith/ui": "^0.3.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^19.2.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/react": "^19.2.3"
30
+ "@types/react": "^19.2.18"
31
31
  }
32
32
  }
@@ -0,0 +1,53 @@
1
+ import type { AuthPageModel } from '@meith/theme-kit'
2
+
3
+ import { MUTED_LINK } from '../shared'
4
+
5
+ export function AuthPage({ title, alert, links, regions }: AuthPageModel) {
6
+ return (
7
+ <main
8
+ id="board-content"
9
+ tabIndex={-1}
10
+ className="flex flex-1 flex-col items-center justify-center px-6 py-12"
11
+ >
12
+ <div className="flex w-full max-w-sm flex-col gap-6 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-sm">
13
+ <div className="flex flex-col gap-1">
14
+ <h1 className="font-heading text-2xl font-semibold text-foreground">{title}</h1>
15
+ {regions.lede !== undefined && (
16
+ <p className="text-sm text-muted-foreground">{regions.lede}</p>
17
+ )}
18
+ </div>
19
+
20
+ {alert !== null && (
21
+ <p
22
+ role="alert"
23
+ className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm"
24
+ >
25
+ {alert}
26
+ </p>
27
+ )}
28
+
29
+ {regions.note !== undefined && (
30
+ <p className="text-sm text-muted-foreground">{regions.note}</p>
31
+ )}
32
+
33
+ {regions.form}
34
+
35
+ {links.length > 0 && (
36
+ <div className="flex flex-col gap-1 text-sm text-muted-foreground">
37
+ {links.map((link) => (
38
+ <span key={link.href}>
39
+ {link.lead === null ? null : `${link.lead} `}
40
+ <a
41
+ href={link.href}
42
+ className={link.lead === null ? MUTED_LINK : `font-medium text-foreground ${MUTED_LINK}`}
43
+ >
44
+ {link.label}
45
+ </a>
46
+ </span>
47
+ ))}
48
+ </div>
49
+ )}
50
+ </div>
51
+ </main>
52
+ )
53
+ }
@@ -0,0 +1,126 @@
1
+ import { Card, CardRows, Empty, EmptyDescription, cn } from '@meith/ui'
2
+ import type { DiscoveryViewModel, TabModel } from '@meith/theme-kit'
3
+
4
+ import { LINK, NUMERIC, Stamp, pageAt } from '../shared'
5
+
6
+ const TAB =
7
+ 'inline-flex h-9 items-center rounded-full px-3.5 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
8
+
9
+ function Tabs({ label, tabs }: { label: string; tabs: readonly TabModel[] }) {
10
+ if (tabs.length === 0) return null
11
+
12
+ return (
13
+ <nav aria-label={label} className="flex items-center gap-x-3">
14
+ <ul className="-my-1.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto py-1.5">
15
+ {tabs.map((tab) => (
16
+ <li key={tab.href} className="shrink-0">
17
+ <a
18
+ href={tab.href}
19
+ {...(tab.isCurrent ? { 'aria-current': 'page' as const } : {})}
20
+ className={cn(
21
+ TAB,
22
+ tab.isCurrent
23
+ ? 'bg-primary font-semibold text-primary-foreground'
24
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground',
25
+ )}
26
+ >
27
+ {tab.label}
28
+ </a>
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ </nav>
33
+ )
34
+ }
35
+
36
+ export function DiscoveryView({
37
+ title,
38
+ blurb,
39
+ tabsLabel,
40
+ tabs,
41
+ rows,
42
+ nextHref,
43
+ nextLabel,
44
+ emptyMessage,
45
+ refusal,
46
+ }: DiscoveryViewModel) {
47
+ return (
48
+ <main
49
+ id="board-content"
50
+ tabIndex={-1}
51
+ className={`${pageAt('max-w-4xl')} flex flex-1 flex-col gap-6 py-8`}
52
+ >
53
+ <div className="flex flex-col gap-1">
54
+ <h1 className="font-heading text-2xl font-semibold">{title}</h1>
55
+ <p className="text-sm text-muted-foreground">{blurb}</p>
56
+ </div>
57
+
58
+ <Tabs label={tabsLabel} tabs={tabs} />
59
+
60
+ {refusal !== null ? (
61
+ <p
62
+ role="alert"
63
+ className="rounded-md border border-border bg-muted px-3 py-2 text-sm"
64
+ >
65
+ {refusal.message}{' '}
66
+ <a href={refusal.signInHref} className={`font-medium text-foreground ${LINK}`}>
67
+ {refusal.signInLabel}
68
+ </a>
69
+ </p>
70
+ ) : rows.length === 0 ? (
71
+ <Card>
72
+ <Empty>
73
+ <EmptyDescription>{emptyMessage}</EmptyDescription>
74
+ </Empty>
75
+ </Card>
76
+ ) : (
77
+ <Card>
78
+ <CardRows>
79
+ {rows.map((row) => (
80
+ <li
81
+ key={row.threadId}
82
+ className="flex flex-col gap-1.5 px-4 py-3.5 transition-colors hover:bg-accent sm:flex-row sm:items-center sm:justify-between sm:gap-6"
83
+ >
84
+ <div className="flex min-w-0 flex-col gap-1">
85
+ <a
86
+ href={row.href}
87
+ className="truncate text-[0.9375rem] font-semibold text-foreground hover:underline"
88
+ >
89
+ {row.title}
90
+ </a>
91
+ <p className="truncate text-xs text-muted-foreground">
92
+ in{' '}
93
+ <a href={row.forum.href} className="font-medium hover:underline">
94
+ {row.forum.label}
95
+ </a>{' '}
96
+ · started by {row.authorUsername}
97
+ </p>
98
+ </div>
99
+
100
+ <p className="shrink-0 text-xs text-muted-foreground sm:text-right">
101
+ <span className={cn('font-semibold text-foreground', NUMERIC)}>
102
+ {row.replyCount}
103
+ </span>{' '}
104
+ {row.replyCount === 1 ? 'reply' : 'replies'}
105
+ <span className="block">
106
+ last post <Stamp at={row.lastPostAt} />
107
+ {row.lastPostUsername === null ? null : ` by ${row.lastPostUsername}`}
108
+ </span>
109
+ </p>
110
+ </li>
111
+ ))}
112
+ </CardRows>
113
+ </Card>
114
+ )}
115
+
116
+ {nextHref !== null && (
117
+ <a
118
+ href={nextHref}
119
+ className="inline-flex h-9 w-fit items-center rounded-full bg-secondary px-4 text-sm font-semibold text-secondary-foreground transition-colors hover:bg-accent"
120
+ >
121
+ {nextLabel} →
122
+ </a>
123
+ )}
124
+ </main>
125
+ )
126
+ }
@@ -1,7 +1,7 @@
1
1
  import { Avatar, Card, CardContent, CardHeader, CardTitle, buttonVariants, cn } from '@meith/ui'
2
2
  import type { MemberProfileModel } from '@meith/theme-kit'
3
3
 
4
- import { NUMERIC, Stamp, pageAt } from '../shared'
4
+ import { NUMERIC, PAGE_BODY, Stamp } from '../shared'
5
5
 
6
6
  export function MemberProfile({
7
7
  user,
@@ -16,7 +16,7 @@ export function MemberProfile({
16
16
  regions,
17
17
  }: MemberProfileModel) {
18
18
  return (
19
- <div className={`${pageAt('max-w-4xl')} flex w-full flex-col gap-4 py-6 sm:py-8`}>
19
+ <div className={PAGE_BODY}>
20
20
  <Card>
21
21
  <CardContent className="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between">
22
22
  <div className="flex min-w-0 items-center gap-4">
@@ -0,0 +1,106 @@
1
+ import { Disclosure, cn } from '@meith/ui'
2
+ import type { PanelNavItemModel, PanelNavModel } from '@meith/theme-kit'
3
+
4
+ const ITEM =
5
+ 'flex items-baseline gap-2 rounded-lg px-3 py-2 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
6
+
7
+ const HERE = 'bg-secondary font-semibold text-foreground'
8
+ const OPEN = 'font-semibold text-foreground hover:bg-accent'
9
+ const ELSEWHERE = 'text-muted-foreground hover:bg-accent hover:text-foreground'
10
+
11
+ function Count({ count }: { count: number }) {
12
+ return (
13
+ <>
14
+ <span
15
+ aria-hidden="true"
16
+ className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs font-semibold tabular-nums text-foreground"
17
+ >
18
+ {count > 99 ? '99+' : count}
19
+ </span>
20
+ <span className="sr-only">({count} waiting)</span>
21
+ </>
22
+ )
23
+ }
24
+
25
+ function Item({ item, className }: { item: PanelNavItemModel; className: string }) {
26
+ const body = (
27
+ <>
28
+ <span className="min-w-0 flex-1">{item.title}</span>
29
+ {item.count !== null && <Count count={item.count} />}
30
+ </>
31
+ )
32
+
33
+ if (item.isRecord) {
34
+ return (
35
+ <span className={cn(ITEM, HERE, className)} aria-current="page">
36
+ {body}
37
+ </span>
38
+ )
39
+ }
40
+
41
+ return (
42
+ <a
43
+ href={item.href}
44
+ className={cn(ITEM, className)}
45
+ {...(item.current === null
46
+ ? {}
47
+ : { 'aria-current': item.current === 'here' ? ('page' as const) : ('true' as const) })}
48
+ >
49
+ {body}
50
+ </a>
51
+ )
52
+ }
53
+
54
+ function Sections({ label, sections }: Pick<PanelNavModel, 'label' | 'sections'>) {
55
+ return (
56
+ <nav aria-label={label} className="text-sm">
57
+ <ul className="flex flex-col gap-0.5">
58
+ {sections.map((section) => (
59
+ <li
60
+ key={section.href}
61
+ className={cn(section.isOverview && 'mb-1 border-b border-border pb-1')}
62
+ >
63
+ <Item
64
+ item={section}
65
+ className={
66
+ section.current === 'here' ? HERE : section.isOpen ? OPEN : ELSEWHERE
67
+ }
68
+ />
69
+
70
+ {section.isOpen && section.children.length > 0 && (
71
+ <ul className="mt-0.5 ml-3 flex flex-col gap-0.5 border-l border-border pl-2">
72
+ {section.children.map((child) => (
73
+ <li key={child.href}>
74
+ <Item
75
+ item={child}
76
+ className={child.current === 'here' ? HERE : ELSEWHERE}
77
+ />
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ )}
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ </nav>
86
+ )
87
+ }
88
+
89
+ export function PanelNav({ label, sections, currentTitle }: PanelNavModel) {
90
+ return (
91
+ <>
92
+ <Disclosure
93
+ summary={label}
94
+ className="lg:hidden"
95
+ contentClassName="p-2"
96
+ {...(currentTitle === null ? {} : { aside: currentTitle })}
97
+ >
98
+ <Sections label={label} sections={sections} />
99
+ </Disclosure>
100
+
101
+ <div className="hidden rounded-lg border border-border bg-card p-2 shadow-elevation lg:block">
102
+ <Sections label={label} sections={sections} />
103
+ </div>
104
+ </>
105
+ )
106
+ }
@@ -0,0 +1,66 @@
1
+ import { cn } from '@meith/ui'
2
+ import type { PanelPageModel, PanelSectionModel } from '@meith/theme-kit'
3
+
4
+ import { MUTED_LINK } from '../shared'
5
+
6
+ export function PanelPage({ title, back, width, gap, regions, children }: PanelPageModel) {
7
+ return (
8
+ <main
9
+ id="board-content"
10
+ tabIndex={-1}
11
+ className={cn(
12
+ 'flex w-full flex-col px-6 py-8',
13
+ width === 'wide' ? 'max-w-none' : 'max-w-4xl',
14
+ gap === 'loose' ? 'gap-8' : 'gap-6',
15
+ )}
16
+ >
17
+ <div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
18
+ <div className="flex min-w-0 flex-col gap-1">
19
+ {back !== null && (
20
+ <a href={back.href} className={`text-sm font-medium ${MUTED_LINK}`}>
21
+ ← {back.label}
22
+ </a>
23
+ )}
24
+
25
+ <h1 className="font-heading text-2xl font-semibold text-foreground">{title}</h1>
26
+
27
+ {regions.lede !== undefined && (
28
+ <p className="text-sm text-muted-foreground">{regions.lede}</p>
29
+ )}
30
+ {regions.meta !== undefined && (
31
+ <p className="text-xs text-muted-foreground">{regions.meta}</p>
32
+ )}
33
+ </div>
34
+
35
+ {regions.actions !== undefined && (
36
+ <div className="flex shrink-0 flex-wrap items-center gap-2">{regions.actions}</div>
37
+ )}
38
+ </div>
39
+
40
+ {children}
41
+ </main>
42
+ )
43
+ }
44
+
45
+ export function PanelSection({ title, headingId, regions, children }: PanelSectionModel) {
46
+ return (
47
+ <section aria-labelledby={headingId} className="flex flex-col gap-3">
48
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-1">
49
+ <div className="flex min-w-0 flex-col gap-0.5">
50
+ <h2 id={headingId} className="font-heading text-lg font-semibold text-foreground">
51
+ {title}
52
+ </h2>
53
+ {regions.description !== undefined && (
54
+ <p className="text-sm text-muted-foreground">{regions.description}</p>
55
+ )}
56
+ </div>
57
+
58
+ {regions.actions !== undefined && (
59
+ <div className="flex shrink-0 flex-wrap items-center gap-2">{regions.actions}</div>
60
+ )}
61
+ </div>
62
+
63
+ {children}
64
+ </section>
65
+ )
66
+ }
@@ -0,0 +1,35 @@
1
+ import type { PanelShellModel } from '@meith/theme-kit'
2
+
3
+ import { MUTED_LINK } from '../shared'
4
+
5
+ const RAIL =
6
+ 'flex flex-col gap-4 px-6 pt-6 lg:sticky lg:w-56 lg:shrink-0 lg:self-start lg:py-8 lg:pr-0'
7
+
8
+ export function PanelShell({ panel, links, linksLabel, regions, children }: PanelShellModel) {
9
+ return (
10
+ <div className="mx-auto flex w-full max-w-6xl flex-1 flex-col lg:flex-row">
11
+ <aside className={`${RAIL} ${panel === 'admincp' ? 'lg:top-14' : 'lg:top-6'}`}>
12
+ {regions.nav}
13
+
14
+ {links.length > 0 && (
15
+ <nav aria-label={linksLabel} className="border-t border-border pt-3 text-sm">
16
+ <ul className="flex flex-col gap-0.5">
17
+ {links.map((link) => (
18
+ <li key={link.href}>
19
+ <a
20
+ href={link.href}
21
+ className={`block rounded-md px-3 py-1.5 ${MUTED_LINK} hover:bg-muted/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`}
22
+ >
23
+ {link.label}
24
+ </a>
25
+ </li>
26
+ ))}
27
+ </ul>
28
+ </nav>
29
+ )}
30
+ </aside>
31
+
32
+ <div className="min-w-0 flex-1">{children}</div>
33
+ </div>
34
+ )
35
+ }
@@ -0,0 +1,106 @@
1
+ import {
2
+ Card,
3
+ CardContent,
4
+ CardFooter,
5
+ CardRows,
6
+ Empty,
7
+ EmptyDescription,
8
+ EmptyTitle,
9
+ Field,
10
+ Input,
11
+ buttonVariants,
12
+ } from '@meith/ui'
13
+ import type { SearchResultsModel } from '@meith/theme-kit'
14
+
15
+ import { LINK, Stamp, pageAt } from '../shared'
16
+
17
+ export function SearchResults({
18
+ terms,
19
+ searchedAt,
20
+ hits,
21
+ nextHref,
22
+ nextLabel,
23
+ newSearchHref,
24
+ within,
25
+ }: SearchResultsModel) {
26
+ return (
27
+ <main
28
+ id="board-content"
29
+ tabIndex={-1}
30
+ className={`${pageAt('max-w-3xl')} flex flex-1 flex-col gap-6 py-8`}
31
+ >
32
+ <div className="flex flex-col gap-1">
33
+ <h1 className="font-heading text-2xl font-semibold">
34
+ Results for &ldquo;{terms}&rdquo;
35
+ </h1>
36
+ <p className="text-sm text-muted-foreground">
37
+ Searched <Stamp at={searchedAt} />. Results are checked against your access
38
+ every time this page is opened, so they can change.
39
+ </p>
40
+ </div>
41
+
42
+ <Card>
43
+ {hits.length === 0 ? (
44
+ <Empty>
45
+ <EmptyTitle>Nothing matched.</EmptyTitle>
46
+ <EmptyDescription>
47
+ Or nothing you can see does. Try fewer words, or a different spelling.
48
+ </EmptyDescription>
49
+ </Empty>
50
+ ) : (
51
+ <CardRows>
52
+ {hits.map((hit) => (
53
+ <li key={hit.postId} className="flex flex-col gap-1 px-4 py-3">
54
+ <a href={hit.href} className={`text-sm font-medium text-foreground ${LINK}`}>
55
+ {hit.threadTitle}
56
+ </a>
57
+ <p
58
+ className="text-sm text-muted-foreground [&_b]:font-semibold [&_b]:text-foreground"
59
+ dangerouslySetInnerHTML={{ __html: hit.excerptHtml }}
60
+ />
61
+ <p className="text-xs text-muted-foreground">
62
+ {hit.authorUsername} · <Stamp at={hit.postedAt} />
63
+ </p>
64
+ </li>
65
+ ))}
66
+ </CardRows>
67
+ )}
68
+
69
+ {nextHref !== null && (
70
+ <CardFooter>
71
+ <a href={nextHref} className={`font-medium text-foreground ${LINK}`}>
72
+ {nextLabel} →
73
+ </a>
74
+ </CardFooter>
75
+ )}
76
+ </Card>
77
+
78
+ <Card>
79
+ <CardContent className="p-4 sm:p-5">
80
+ <form method="get" action={within.action} className="flex flex-col gap-4">
81
+ <Field name={within.field} label={within.label} description={within.hint}>
82
+ {(control) => (
83
+ <Input
84
+ {...control}
85
+ defaultValue={within.value}
86
+ type="search"
87
+ autoComplete="off"
88
+ />
89
+ )}
90
+ </Field>
91
+
92
+ <div>
93
+ <button type="submit" className={buttonVariants({ variant: 'primary' })}>
94
+ {within.submitLabel}
95
+ </button>
96
+ </div>
97
+ </form>
98
+ </CardContent>
99
+ </Card>
100
+
101
+ <a href={newSearchHref} className={`text-sm font-medium text-foreground ${LINK}`}>
102
+ Start a new search
103
+ </a>
104
+ </main>
105
+ )
106
+ }
package/src/theme.ts CHANGED
@@ -25,6 +25,12 @@ import { PostActions } from './slots/post-actions'
25
25
  import { MemberProfile } from './slots/member-profile'
26
26
  import { ForumJump } from './slots/forum-jump'
27
27
  import { SearchForm } from './slots/search-form'
28
+ import { AuthPage } from './slots/auth-page'
29
+ import { SearchResults } from './slots/search-results'
30
+ import { DiscoveryView } from './slots/discovery-view'
31
+ import { PanelShell } from './slots/panel-shell'
32
+ import { PanelNav } from './slots/panel-nav'
33
+ import { PanelPage, PanelSection } from './slots/panel-page'
28
34
  import { RedirectNotice } from './slots/redirect-notice'
29
35
  import { ErrorNotice } from './slots/error-notice'
30
36
 
@@ -61,7 +67,18 @@ export const defaultTheme = defineTheme({
61
67
 
62
68
  MemberProfile,
63
69
 
70
+ AuthPage,
71
+
64
72
  SearchForm,
73
+ SearchResults,
74
+
75
+ DiscoveryView,
76
+
77
+ PanelShell,
78
+ PanelNav,
79
+ PanelPage,
80
+ PanelSection,
81
+
65
82
  ForumJump,
66
83
 
67
84
  RedirectNotice,