@meith/theme-phasebook 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/theme-phasebook",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A theme for Meith with a familiar social shape, built on the default theme's slots.",
5
5
  "license": "LGPL-3.0-or-later",
6
6
  "repository": {
@@ -20,14 +20,14 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/theme-kit": "^0.1.0",
24
- "@meith/theme-default": "^0.1.0",
25
- "@meith/ui": "^0.1.0"
23
+ "@meith/theme-default": "^0.2.0",
24
+ "@meith/theme-kit": "^0.2.0",
25
+ "@meith/ui": "^0.2.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": "^19.2.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@types/react": "^19.2.3"
31
+ "@types/react": "^19.2.18"
32
32
  }
33
33
  }
@@ -0,0 +1,50 @@
1
+ import type { AuthPageModel } from '@meith/theme-kit'
2
+
3
+ import { 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-3 py-8 sm:px-4"
11
+ >
12
+ <div className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-elevation">
13
+ <div className="flex flex-col gap-1">
14
+ <h1 className="text-xl leading-tight font-bold tracking-tight">{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-lg bg-destructive/12 px-3 py-2 text-sm font-semibold text-destructive"
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 border-t border-border pt-3 text-sm text-muted-foreground">
37
+ {links.map((link) => (
38
+ <span key={link.href}>
39
+ {link.lead === null ? null : `${link.lead} `}
40
+ <a href={link.href} className={`font-semibold ${LINK}`}>
41
+ {link.label}
42
+ </a>
43
+ </span>
44
+ ))}
45
+ </div>
46
+ )}
47
+ </div>
48
+ </main>
49
+ )
50
+ }
@@ -0,0 +1,111 @@
1
+ import { cn } from '@meith/ui'
2
+ import type { DiscoveryViewModel, TabModel } from '@meith/theme-kit'
3
+
4
+ import { Chip, FEED, LINK, PAGE, PILL, Stamp, count, plural } from '../shared'
5
+
6
+ function Tabs({ label, tabs }: { label: string; tabs: readonly TabModel[] }) {
7
+ if (tabs.length === 0) return null
8
+
9
+ return (
10
+ <nav
11
+ aria-label={label}
12
+ className="overflow-hidden rounded-lg border border-border bg-card shadow-elevation"
13
+ >
14
+ <ul className="flex overflow-x-auto">
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
+ 'inline-flex h-11 items-center px-4 text-sm font-semibold whitespace-nowrap transition-colors',
22
+ tab.isCurrent
23
+ ? 'border-b-2 border-primary text-primary'
24
+ : 'border-b-2 border-transparent 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 id="board-content" tabIndex={-1} className={`${PAGE} flex-1 py-4 sm:py-6`}>
49
+ <div className={`${FEED} flex flex-col gap-4`}>
50
+ <header className="rounded-lg border border-border bg-card px-4 py-3.5 shadow-elevation">
51
+ <h1 className="text-xl leading-tight font-bold tracking-tight">{title}</h1>
52
+ <p className="mt-1 text-sm text-muted-foreground">{blurb}</p>
53
+ </header>
54
+
55
+ <Tabs label={tabsLabel} tabs={tabs} />
56
+
57
+ {refusal !== null ? (
58
+ <p
59
+ role="alert"
60
+ className="rounded-lg border border-border bg-card px-4 py-3.5 text-sm shadow-elevation"
61
+ >
62
+ {refusal.message}{' '}
63
+ <a href={refusal.signInHref} className={`font-semibold ${LINK}`}>
64
+ {refusal.signInLabel}
65
+ </a>
66
+ </p>
67
+ ) : rows.length === 0 ? (
68
+ <p className="rounded-lg border border-border bg-card px-4 py-6 text-center text-sm text-muted-foreground shadow-elevation">
69
+ {emptyMessage}
70
+ </p>
71
+ ) : (
72
+ <ul className="flex flex-col gap-3">
73
+ {rows.map((row) => (
74
+ <li
75
+ key={row.threadId}
76
+ className="rounded-lg border border-border bg-card px-4 py-3.5 shadow-elevation"
77
+ >
78
+ <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
79
+ <a
80
+ href={row.href}
81
+ className="min-w-0 text-[0.9375rem] font-semibold hover:underline"
82
+ >
83
+ {row.title}
84
+ </a>
85
+ <Chip>
86
+ {count(row.replyCount)} {plural(row.replyCount, 'reply', 'replies')}
87
+ </Chip>
88
+ </div>
89
+
90
+ <p className="mt-1 text-xs text-muted-foreground">
91
+ in{' '}
92
+ <a href={row.forum.href} className={LINK}>
93
+ {row.forum.label}
94
+ </a>{' '}
95
+ · started by {row.authorUsername} · last post <Stamp at={row.lastPostAt} />
96
+ {row.lastPostUsername === null ? null : ` by ${row.lastPostUsername}`}
97
+ </p>
98
+ </li>
99
+ ))}
100
+ </ul>
101
+ )}
102
+
103
+ {nextHref !== null && (
104
+ <a href={nextHref} className={PILL}>
105
+ {nextLabel} →
106
+ </a>
107
+ )}
108
+ </div>
109
+ </main>
110
+ )
111
+ }
@@ -0,0 +1,100 @@
1
+ import { Disclosure, cn } from '@meith/ui'
2
+ import type { PanelNavItemModel, PanelNavModel } from '@meith/theme-kit'
3
+
4
+ import { Chip } from '../shared'
5
+
6
+ const ITEM =
7
+ 'flex items-center gap-2 rounded-full px-3 py-2 text-sm font-semibold transition-colors duration-100'
8
+
9
+ const HERE = 'bg-primary/12 text-primary'
10
+ const OPEN = 'text-foreground hover:bg-accent'
11
+ const ELSEWHERE = 'text-muted-foreground hover:bg-accent hover:text-foreground'
12
+
13
+ function Item({ item, className }: { item: PanelNavItemModel; className: string }) {
14
+ const body = (
15
+ <>
16
+ <span className="min-w-0 flex-1 truncate">{item.title}</span>
17
+ {item.count !== null && (
18
+ <Chip className="bg-primary/12 text-primary">
19
+ {item.count > 99 ? '99+' : item.count}
20
+ <span className="sr-only"> waiting</span>
21
+ </Chip>
22
+ )}
23
+ </>
24
+ )
25
+
26
+ if (item.isRecord) {
27
+ return (
28
+ <span className={cn(ITEM, HERE, className)} aria-current="page">
29
+ {body}
30
+ </span>
31
+ )
32
+ }
33
+
34
+ return (
35
+ <a
36
+ href={item.href}
37
+ className={cn(ITEM, className)}
38
+ {...(item.current === null
39
+ ? {}
40
+ : { 'aria-current': item.current === 'here' ? ('page' as const) : ('true' as const) })}
41
+ >
42
+ {body}
43
+ </a>
44
+ )
45
+ }
46
+
47
+ function Sections({ label, sections }: Pick<PanelNavModel, 'label' | 'sections'>) {
48
+ return (
49
+ <nav aria-label={label}>
50
+ <ul className="flex flex-col">
51
+ {sections.map((section) => (
52
+ <li
53
+ key={section.href}
54
+ className={cn(section.isOverview && 'mb-1 border-b border-border pb-1')}
55
+ >
56
+ <Item
57
+ item={section}
58
+ className={section.current === 'here' ? HERE : section.isOpen ? OPEN : ELSEWHERE}
59
+ />
60
+
61
+ {section.isOpen && section.children.length > 0 && (
62
+ <ul className="mt-1 ml-4 flex flex-col border-l border-border pl-2">
63
+ {section.children.map((child) => (
64
+ <li key={child.href}>
65
+ <Item
66
+ item={child}
67
+ className={cn(
68
+ 'text-[0.8125rem]',
69
+ child.current === 'here' ? HERE : ELSEWHERE,
70
+ )}
71
+ />
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ )}
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ </nav>
80
+ )
81
+ }
82
+
83
+ export function PanelNav({ label, sections, currentTitle }: PanelNavModel) {
84
+ return (
85
+ <>
86
+ <Disclosure
87
+ summary={label}
88
+ className="lg:hidden"
89
+ contentClassName="p-2"
90
+ {...(currentTitle === null ? {} : { aside: currentTitle })}
91
+ >
92
+ <Sections label={label} sections={sections} />
93
+ </Disclosure>
94
+
95
+ <div className="hidden rounded-lg border border-border bg-card p-2 shadow-elevation lg:block">
96
+ <Sections label={label} sections={sections} />
97
+ </div>
98
+ </>
99
+ )
100
+ }
@@ -0,0 +1,73 @@
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',
13
+ width === 'wide' ? 'max-w-none' : 'max-w-4xl',
14
+ gap === 'loose' ? 'gap-4' : 'gap-3',
15
+ )}
16
+ >
17
+ <header className="rounded-lg border border-border bg-card px-4 py-3.5 shadow-elevation">
18
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
19
+ <div className="flex min-w-0 flex-col gap-1">
20
+ {back !== null && (
21
+ <a href={back.href} className={`text-xs font-semibold ${MUTED_LINK}`}>
22
+ ← {back.label}
23
+ </a>
24
+ )}
25
+
26
+ <h1 className="text-xl leading-tight font-bold tracking-tight text-balance">
27
+ {title}
28
+ </h1>
29
+
30
+ {regions.lede !== undefined && (
31
+ <p className="text-sm text-muted-foreground">{regions.lede}</p>
32
+ )}
33
+ {regions.meta !== undefined && (
34
+ <p className="text-xs text-muted-foreground">{regions.meta}</p>
35
+ )}
36
+ </div>
37
+
38
+ {regions.actions !== undefined && (
39
+ <div className="flex shrink-0 flex-wrap items-center gap-2">{regions.actions}</div>
40
+ )}
41
+ </div>
42
+ </header>
43
+
44
+ {children}
45
+ </main>
46
+ )
47
+ }
48
+
49
+ export function PanelSection({ title, headingId, regions, children }: PanelSectionModel) {
50
+ return (
51
+ <section aria-labelledby={headingId} className="flex flex-col gap-3">
52
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-1 px-1">
53
+ <div className="flex min-w-0 flex-col gap-0.5">
54
+ <h2
55
+ id={headingId}
56
+ className="text-[0.9375rem] font-semibold text-muted-foreground"
57
+ >
58
+ {title}
59
+ </h2>
60
+ {regions.description !== undefined && (
61
+ <p className="text-sm text-muted-foreground">{regions.description}</p>
62
+ )}
63
+ </div>
64
+
65
+ {regions.actions !== undefined && (
66
+ <div className="flex shrink-0 flex-wrap items-center gap-2">{regions.actions}</div>
67
+ )}
68
+ </div>
69
+
70
+ {children}
71
+ </section>
72
+ )
73
+ }
@@ -0,0 +1,39 @@
1
+ import type { PanelShellModel } from '@meith/theme-kit'
2
+
3
+ import { MUTED_LINK } 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 gap-4 px-3 py-4 sm:px-4 lg:flex-row lg:py-6">
8
+ <aside
9
+ className={`flex flex-col gap-3 lg:sticky lg:w-60 lg:shrink-0 lg:self-start ${
10
+ panel === 'admincp' ? 'lg:top-16' : 'lg:top-4'
11
+ }`}
12
+ >
13
+ {regions.nav}
14
+
15
+ {links.length > 0 && (
16
+ <nav
17
+ aria-label={linksLabel}
18
+ className="rounded-lg border border-border bg-card p-2 text-sm shadow-elevation"
19
+ >
20
+ <ul className="flex flex-col">
21
+ {links.map((link) => (
22
+ <li key={link.href}>
23
+ <a
24
+ href={link.href}
25
+ className={`block rounded-full px-3 py-2 font-semibold ${MUTED_LINK} hover:bg-accent hover:text-foreground hover:no-underline`}
26
+ >
27
+ {link.label}
28
+ </a>
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ </nav>
33
+ )}
34
+ </aside>
35
+
36
+ <div className="min-w-0 flex-1">{children}</div>
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,101 @@
1
+ import type { SearchResultsModel } from '@meith/theme-kit'
2
+
3
+ import { FEED, LINK, PAGE, PILL, PILL_PRIMARY, Stamp } from '../shared'
4
+
5
+ export function SearchResults({
6
+ terms,
7
+ searchedAt,
8
+ hits,
9
+ nextHref,
10
+ nextLabel,
11
+ newSearchHref,
12
+ within,
13
+ }: SearchResultsModel) {
14
+ return (
15
+ <main id="board-content" tabIndex={-1} className={`${PAGE} flex-1 py-4 sm:py-6`}>
16
+ <div className={`${FEED} flex flex-col gap-4`}>
17
+ <header className="rounded-lg border border-border bg-card px-4 py-3.5 shadow-elevation">
18
+ <h1 className="text-xl leading-tight font-bold tracking-tight text-balance">
19
+ Results for &ldquo;{terms}&rdquo;
20
+ </h1>
21
+ <p className="mt-1 text-sm text-muted-foreground">
22
+ Searched <Stamp at={searchedAt} />. Results are checked against your access
23
+ every time this page is opened, so they can change.
24
+ </p>
25
+ </header>
26
+
27
+ {hits.length === 0 ? (
28
+ <p className="rounded-lg border border-border bg-card px-4 py-6 text-center text-sm text-muted-foreground shadow-elevation">
29
+ Nothing matched — or nothing you can see does. Try fewer words, or a different
30
+ spelling.
31
+ </p>
32
+ ) : (
33
+ <ul className="flex flex-col gap-3">
34
+ {hits.map((hit) => (
35
+ <li
36
+ key={hit.postId}
37
+ className="rounded-lg border border-border bg-card px-4 py-3.5 shadow-elevation"
38
+ >
39
+ <a href={hit.href} className="text-[0.9375rem] font-semibold hover:underline">
40
+ {hit.threadTitle}
41
+ </a>
42
+ <p
43
+ className="mt-1 text-sm text-muted-foreground [&_b]:font-semibold [&_b]:text-foreground"
44
+ dangerouslySetInnerHTML={{ __html: hit.excerptHtml }}
45
+ />
46
+ <p className="mt-1.5 text-xs text-muted-foreground">
47
+ {hit.authorUsername} · <Stamp at={hit.postedAt} />
48
+ </p>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ )}
53
+
54
+ {nextHref !== null && (
55
+ <a href={nextHref} className={PILL}>
56
+ {nextLabel} →
57
+ </a>
58
+ )}
59
+
60
+ <section className="rounded-lg border border-border bg-card shadow-elevation">
61
+ <form
62
+ method="get"
63
+ action={within.action}
64
+ className="flex flex-col gap-3 px-4 py-4"
65
+ >
66
+ <div>
67
+ <label
68
+ htmlFor="search-within"
69
+ className="mb-1 block text-sm font-semibold text-foreground"
70
+ >
71
+ {within.label}
72
+ </label>
73
+ <input
74
+ id="search-within"
75
+ type="search"
76
+ name={within.field}
77
+ defaultValue={within.value}
78
+ autoComplete="off"
79
+ aria-describedby="search-within-hint"
80
+ className="w-full rounded-full border border-input bg-surface px-4 py-2.5 text-sm text-foreground transition-colors focus-visible:border-ring"
81
+ />
82
+ <p id="search-within-hint" className="mt-1.5 text-xs text-muted-foreground">
83
+ {within.hint}
84
+ </p>
85
+ </div>
86
+
87
+ <div>
88
+ <button type="submit" className={PILL_PRIMARY}>
89
+ {within.submitLabel}
90
+ </button>
91
+ </div>
92
+ </form>
93
+ </section>
94
+
95
+ <a href={newSearchHref} className={`text-sm font-semibold ${LINK}`}>
96
+ Start a new search
97
+ </a>
98
+ </div>
99
+ </main>
100
+ )
101
+ }
package/src/theme.ts CHANGED
@@ -2,9 +2,11 @@ import { defaultTheme } from '@meith/theme-default'
2
2
  import { defineTheme } from '@meith/theme-kit'
3
3
 
4
4
  import { Announcement } from './slots/announcement'
5
+ import { AuthPage } from './slots/auth-page'
5
6
  import { BoardIndex } from './slots/board-index'
6
7
  import { BoardStats } from './slots/board-stats'
7
8
  import { CategoryBlock } from './slots/category-block'
9
+ import { DiscoveryView } from './slots/discovery-view'
8
10
  import { ErrorNotice } from './slots/error-notice'
9
11
  import { Footer } from './slots/footer'
10
12
  import { ForumJump } from './slots/forum-jump'
@@ -17,11 +19,15 @@ import { MemberProfile } from './slots/member-profile'
17
19
  import { Navigation } from './slots/navigation'
18
20
  import { Notice } from './slots/notice'
19
21
  import { Pagination } from './slots/pagination'
22
+ import { PanelNav } from './slots/panel-nav'
23
+ import { PanelPage, PanelSection } from './slots/panel-page'
24
+ import { PanelShell } from './slots/panel-shell'
20
25
  import { PostActions } from './slots/post-actions'
21
26
  import { PostBit } from './slots/post-bit'
22
27
  import { PostForm } from './slots/post-form'
23
28
  import { RedirectNotice } from './slots/redirect-notice'
24
29
  import { SearchForm } from './slots/search-form'
30
+ import { SearchResults } from './slots/search-results'
25
31
  import { Shell } from './slots/shell'
26
32
  import { SubforumList } from './slots/subforum-list'
27
33
  import { ThreadRow } from './slots/thread-row'
@@ -63,7 +69,18 @@ export const phasebookTheme = defineTheme({
63
69
 
64
70
  MemberProfile,
65
71
 
72
+ AuthPage,
73
+
66
74
  SearchForm,
75
+ SearchResults,
76
+
77
+ DiscoveryView,
78
+
79
+ PanelShell,
80
+ PanelNav,
81
+ PanelPage,
82
+ PanelSection,
83
+
67
84
  ForumJump,
68
85
 
69
86
  RedirectNotice,