@meith/theme-phasebook 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,108 @@
1
+ import type { ForumDisplayModel } from '@meith/theme-kit'
2
+
3
+ import {
4
+ Circle,
5
+ FEED,
6
+ NUMERIC,
7
+ PAGE,
8
+ PILL,
9
+ PILL_PRIMARY,
10
+ count,
11
+ isEmptyRegion,
12
+ plural,
13
+ } from '../shared'
14
+
15
+ export function ForumDisplay({ forum, newThreadHref, markReadAction, regions }: ForumDisplayModel) {
16
+ return (
17
+ <div className={`${PAGE} py-4 sm:py-6`}>
18
+ <div className={`${FEED} flex flex-col gap-4`}>
19
+ <section className="overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
20
+ <div className="h-24 bg-primary/12" />
21
+
22
+ <div className="px-4 pb-4">
23
+ <div className="-mt-10 flex flex-wrap items-end justify-between gap-x-4 gap-y-3">
24
+ <div className="flex min-w-0 items-end gap-3">
25
+ <Circle name={forum.title} size={72} className="ring-4 ring-card" />
26
+
27
+ <div className="min-w-0">
28
+ <h1 className="text-xl leading-tight font-bold tracking-tight text-balance">
29
+ {forum.title}
30
+ </h1>
31
+ {forum.type !== 'link' && (
32
+ <p className={`mt-0.5 text-xs text-muted-foreground ${NUMERIC}`}>
33
+ {count(forum.threadCount)} {plural(forum.threadCount, 'thread', 'threads')} ·{' '}
34
+ {count(forum.postCount)} {plural(forum.postCount, 'post', 'posts')}
35
+ </p>
36
+ )}
37
+ </div>
38
+ </div>
39
+
40
+ {markReadAction !== null && (
41
+ <form action={markReadAction} method="post" className="shrink-0">
42
+ <button type="submit" className={PILL}>
43
+ Mark read
44
+ </button>
45
+ </form>
46
+ )}
47
+ </div>
48
+
49
+ {forum.description !== null && (
50
+ <p className="mt-3 text-sm text-muted-foreground">{forum.description}</p>
51
+ )}
52
+ </div>
53
+ </section>
54
+
55
+ {newThreadHref !== null && (
56
+ <div className="flex items-center gap-2.5 rounded-lg border border-border bg-card px-4 py-3 shadow-elevation">
57
+ <Circle name={forum.title} size={40} />
58
+ <a
59
+ href={newThreadHref}
60
+ className="min-w-0 flex-1 truncate rounded-full bg-secondary px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
61
+ >
62
+ Start a thread in {forum.title}…
63
+ </a>
64
+ <a href={newThreadHref} className={`hidden sm:inline-flex ${PILL_PRIMARY}`}>
65
+ New thread
66
+ </a>
67
+ </div>
68
+ )}
69
+
70
+ {regions.tools !== undefined && (
71
+ <div className="flex flex-col gap-3 empty:hidden">{regions.tools}</div>
72
+ )}
73
+
74
+ {regions.announcements !== undefined && (
75
+ <div className="flex flex-col gap-4 empty:hidden">{regions.announcements}</div>
76
+ )}
77
+
78
+ {regions.subforums}
79
+
80
+ {isEmptyRegion(regions.threads) ? (
81
+ <div className="rounded-lg border border-border bg-card px-4 py-10 text-center shadow-elevation">
82
+ <p className="text-base font-semibold">No threads here yet</p>
83
+ <p className="mt-1 text-sm text-muted-foreground">
84
+ {newThreadHref === null
85
+ ? 'Nothing has been posted in this forum.'
86
+ : 'Nothing has been posted in this forum. Yours would be the first.'}
87
+ </p>
88
+ {newThreadHref !== null && (
89
+ <a href={newThreadHref} className={`mt-4 ${PILL_PRIMARY}`}>
90
+ Start the first thread
91
+ </a>
92
+ )}
93
+ </div>
94
+ ) : (
95
+ <ul className="flex flex-col gap-3">{regions.threads}</ul>
96
+ )}
97
+
98
+ {regions.pagination}
99
+
100
+ {regions.afterContent !== undefined && (
101
+ <div className="flex flex-wrap items-center justify-between gap-x-8 gap-y-3 rounded-lg border border-border bg-card px-4 py-3 shadow-elevation empty:hidden">
102
+ {regions.afterContent}
103
+ </div>
104
+ )}
105
+ </div>
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,48 @@
1
+ import type { ForumJumpModel } from '@meith/theme-kit'
2
+
3
+ import { PAGE, PILL } 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="bg-background">
12
+ <form
13
+ method="get"
14
+ action={action}
15
+ className={`${PAGE} flex flex-wrap items-center gap-2 pb-4 sm:justify-end`}
16
+ >
17
+ <label htmlFor={id} className="text-xs font-semibold text-muted-foreground">
18
+ {label}
19
+ </label>
20
+
21
+ <select
22
+ id={id}
23
+ name={field}
24
+ defaultValue={forums.find((forum) => forum.isSelected)?.value ?? ''}
25
+ className="h-9 w-auto min-w-48 flex-1 rounded-full border border-input bg-card px-3 text-xs text-foreground sm:flex-none"
26
+ >
27
+ {forums.map((forum) => {
28
+ const indented = `${'  '.repeat(forum.depth)}${forum.label}`
29
+ return (
30
+ <option
31
+ key={forum.value}
32
+ value={forum.value}
33
+ disabled={forum.isCategory}
34
+ label={indented}
35
+ >
36
+ {indented}
37
+ </option>
38
+ )
39
+ })}
40
+ </select>
41
+
42
+ <button type="submit" className={PILL}>
43
+ {submitLabel}
44
+ </button>
45
+ </form>
46
+ </div>
47
+ )
48
+ }
@@ -0,0 +1,79 @@
1
+ import type { ForumRowSlotModel } from '@meith/theme-kit'
2
+
3
+ import { Circle, MUTED_LINK, NUMERIC, Stamp, UserRef, count, plural } 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="flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent"
12
+ >
13
+ <span className="relative shrink-0">
14
+ <Circle name={forum.title} size={40} />
15
+ {forum.isUnread && (
16
+ <span
17
+ aria-hidden="true"
18
+ className="absolute -top-0.5 -right-0.5 size-3 rounded-full bg-forum-unread ring-2 ring-card"
19
+ />
20
+ )}
21
+ </span>
22
+
23
+ <div className="min-w-0 flex-1">
24
+ <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
25
+ <a
26
+ href={forum.href}
27
+ className={`text-[0.9375rem] hover:underline ${forum.isUnread ? 'font-bold text-foreground' : 'font-semibold text-foreground'}`}
28
+ >
29
+ {forum.title}
30
+ </a>
31
+ {forum.isUnread && <span className="sr-only"> (new posts)</span>}
32
+
33
+ {!isLink && (
34
+ <span className={`shrink-0 text-xs text-muted-foreground ${NUMERIC}`}>
35
+ {count(forum.threadCount)} {plural(forum.threadCount, 'thread', 'threads')} ·{' '}
36
+ {count(forum.postCount)} {plural(forum.postCount, 'post', 'posts')}
37
+ </span>
38
+ )}
39
+ </div>
40
+
41
+ {forum.description !== null && (
42
+ <p className="mt-0.5 text-sm text-muted-foreground">{forum.description}</p>
43
+ )}
44
+
45
+ {forum.subforums.length > 0 && (
46
+ <p className="mt-1.5 flex flex-wrap items-center gap-1.5">
47
+ {forum.subforums.map((sub) => (
48
+ <a
49
+ key={sub.href}
50
+ href={sub.href}
51
+ className="rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
52
+ >
53
+ {sub.label}
54
+ </a>
55
+ ))}
56
+ </p>
57
+ )}
58
+
59
+ {!isLink && (
60
+ <p className="mt-1.5 truncate text-xs text-muted-foreground">
61
+ {forum.lastPost === null ? (
62
+ <span className="text-forum-read">No posts yet</span>
63
+ ) : (
64
+ <>
65
+ <a href={forum.lastPost.href} className={`font-semibold ${MUTED_LINK}`}>
66
+ {forum.lastPost.threadTitle}
67
+ </a>
68
+ {' — '}
69
+ <UserRef user={forum.lastPost.author} className="text-xs font-medium" />
70
+ {' · '}
71
+ <Stamp at={forum.lastPost.at} />
72
+ </>
73
+ )}
74
+ </p>
75
+ )}
76
+ </div>
77
+ </li>
78
+ )
79
+ }
@@ -0,0 +1,90 @@
1
+ import type { HeaderModel } from '@meith/theme-kit'
2
+
3
+ import { PAGE } from '../shared'
4
+
5
+ function BoardMark({ boardTitle, logo }: Pick<HeaderModel, 'boardTitle' | 'logo'>) {
6
+ if (logo === undefined) {
7
+ const initial = (Array.from(boardTitle.trim())[0] ?? '?').toUpperCase()
8
+ return (
9
+ <span
10
+ aria-hidden="true"
11
+ className="inline-flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground"
12
+ >
13
+ {initial}
14
+ </span>
15
+ )
16
+ }
17
+
18
+ const image = (
19
+ <img
20
+ src={logo.src}
21
+ alt=""
22
+ aria-hidden="true"
23
+ className="size-10 shrink-0 rounded-full object-cover"
24
+ decoding="async"
25
+ />
26
+ )
27
+
28
+ if (logo.darkSrc === null) return image
29
+
30
+ return (
31
+ <picture>
32
+ <source media="(prefers-color-scheme: dark)" srcSet={logo.darkSrc} />
33
+ {image}
34
+ </picture>
35
+ )
36
+ }
37
+
38
+ export function Header({ boardTitle, homeHref, navigation, logo, children }: HeaderModel) {
39
+ return (
40
+ <header className="sticky top-0 z-30 border-b border-border bg-card shadow-elevation">
41
+ <div className={`${PAGE} flex h-14 items-center justify-between gap-3`}>
42
+ <a
43
+ href={homeHref}
44
+ className="flex min-w-0 shrink-0 items-center gap-2 rounded-full transition-opacity hover:opacity-80"
45
+ >
46
+ <BoardMark boardTitle={boardTitle} logo={logo} />
47
+ <span className="hidden truncate text-lg font-bold tracking-tight text-foreground sm:inline">
48
+ {boardTitle}
49
+ </span>
50
+ </a>
51
+
52
+ {navigation.length > 0 && (
53
+ <nav aria-label="Board sections" className="hidden min-w-0 flex-1 justify-center lg:flex">
54
+ <ul className="flex items-center gap-1">
55
+ {navigation.map((item) => (
56
+ <li key={item.href}>
57
+ <a
58
+ href={item.href}
59
+ className="inline-flex h-11 items-center rounded-lg px-6 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
60
+ >
61
+ {item.label}
62
+ </a>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ </nav>
67
+ )}
68
+
69
+ <div className="flex shrink-0 items-center justify-end">{children}</div>
70
+ </div>
71
+
72
+ {navigation.length > 0 && (
73
+ <nav aria-label="Board sections" className="border-t border-border lg:hidden">
74
+ <ul className={`${PAGE} flex items-center gap-1 overflow-x-auto py-1`}>
75
+ {navigation.map((item) => (
76
+ <li key={item.href} className="shrink-0">
77
+ <a
78
+ href={item.href}
79
+ className="inline-flex h-9 items-center rounded-full px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
80
+ >
81
+ {item.label}
82
+ </a>
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ </nav>
87
+ )}
88
+ </header>
89
+ )
90
+ }
@@ -0,0 +1,58 @@
1
+ import type { LatestPostsModel } from '@meith/theme-kit'
2
+
3
+ import { Circle, MUTED_LINK, NUMERIC, Rail, Stamp, UserRef } from '../shared'
4
+
5
+ export function LatestPosts({ posts, capturedAt }: LatestPostsModel) {
6
+ return (
7
+ <Rail
8
+ title="Latest posts"
9
+ titleId="latest-posts-heading"
10
+ action={
11
+ <span className={`text-xs text-muted-foreground ${NUMERIC}`}>
12
+ <Stamp at={capturedAt} />
13
+ </span>
14
+ }
15
+ >
16
+ {posts.length === 0 ? (
17
+ <p className="px-3 pt-1 pb-3 text-xs text-muted-foreground">
18
+ The newest posts you can see will appear here.
19
+ </p>
20
+ ) : (
21
+ <ul className="px-1 pt-1 pb-1">
22
+ {posts.map((post) => (
23
+ <li key={post.href} className="rounded-lg px-2 py-1.5 transition-colors hover:bg-accent">
24
+ <div className="flex items-start gap-2.5">
25
+ <Circle name={post.author.username} size={32} className="mt-0.5" />
26
+
27
+ <div className="min-w-0 flex-1">
28
+ <a
29
+ href={post.href}
30
+ className="line-clamp-2 text-sm font-semibold text-foreground hover:underline"
31
+ >
32
+ {post.threadTitle}
33
+ </a>
34
+
35
+ {post.excerpt !== '' && (
36
+ <p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
37
+ {post.excerpt}
38
+ </p>
39
+ )}
40
+
41
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
42
+ <UserRef user={post.author} className="text-xs font-medium" />
43
+ {' in '}
44
+ <a href={post.forum.href} className={MUTED_LINK}>
45
+ {post.forum.label}
46
+ </a>
47
+ {' · '}
48
+ <Stamp at={post.postedAt} />
49
+ </p>
50
+ </div>
51
+ </div>
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ )}
56
+ </Rail>
57
+ )
58
+ }
@@ -0,0 +1,55 @@
1
+ import type { LatestThreadsModel } from '@meith/theme-kit'
2
+
3
+ import { Circle, MUTED_LINK, NUMERIC, Rail, Stamp, UserRef, count, plural } from '../shared'
4
+
5
+ export function LatestThreads({ threads, capturedAt }: LatestThreadsModel) {
6
+ return (
7
+ <Rail
8
+ title="Latest threads"
9
+ titleId="latest-threads-heading"
10
+ action={
11
+ <span className={`text-xs text-muted-foreground ${NUMERIC}`}>
12
+ <Stamp at={capturedAt} />
13
+ </span>
14
+ }
15
+ >
16
+ {threads.length === 0 ? (
17
+ <p className="px-3 pt-1 pb-3 text-xs text-muted-foreground">
18
+ The newest threads you can see will appear here.
19
+ </p>
20
+ ) : (
21
+ <ul className="px-1 pt-1 pb-1">
22
+ {threads.map((thread) => (
23
+ <li key={thread.href} className="rounded-lg px-2 py-1.5 transition-colors hover:bg-accent">
24
+ <div className="flex items-start gap-2.5">
25
+ <Circle name={thread.author.username} size={32} className="mt-0.5" />
26
+
27
+ <div className="min-w-0 flex-1">
28
+ <a
29
+ href={thread.href}
30
+ className="line-clamp-2 text-sm font-semibold text-foreground hover:underline"
31
+ >
32
+ {thread.title}
33
+ </a>
34
+
35
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
36
+ <UserRef user={thread.author} className="text-xs font-medium" />
37
+ {' in '}
38
+ <a href={thread.forum.href} className={MUTED_LINK}>
39
+ {thread.forum.label}
40
+ </a>
41
+ </p>
42
+
43
+ <p className={`mt-0.5 truncate text-xs text-muted-foreground ${NUMERIC}`}>
44
+ {count(thread.replyCount)} {plural(thread.replyCount, 'reply', 'replies')} ·{' '}
45
+ <Stamp at={thread.startedAt} />
46
+ </p>
47
+ </div>
48
+ </div>
49
+ </li>
50
+ ))}
51
+ </ul>
52
+ )}
53
+ </Rail>
54
+ )
55
+ }
@@ -0,0 +1,130 @@
1
+ import { cn } from '@meith/ui'
2
+ import type { MemberProfileModel } from '@meith/theme-kit'
3
+
4
+ import { Circle, FEED, NUMERIC, PAGE, PILL, PILL_PRIMARY, Stamp, count } 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={`${PAGE} py-4 sm:py-6`}>
20
+ <div className={`${FEED} flex flex-col gap-4`}>
21
+ <section className="overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-elevation">
22
+ <div className="h-32 bg-primary/12 sm:h-40" />
23
+
24
+ <div className="flex flex-col items-center gap-3 px-4 pb-4 sm:flex-row sm:items-end sm:justify-between">
25
+ <div className="-mt-12 flex flex-col items-center gap-3 sm:flex-row sm:items-end">
26
+ <Circle
27
+ src={avatarUrl}
28
+ name={user.username}
29
+ size={112}
30
+ className="ring-4 ring-card"
31
+ />
32
+
33
+ <div className="min-w-0 pb-1 text-center sm:text-left">
34
+ <h1
35
+ className={cn(
36
+ 'text-2xl leading-tight font-bold tracking-tight break-words',
37
+ user.nameClass,
38
+ )}
39
+ >
40
+ {user.username}
41
+ </h1>
42
+ {title !== null && <p className="text-sm text-muted-foreground">{title}</p>}
43
+ </div>
44
+ </div>
45
+
46
+ {actions.length > 0 && (
47
+ <nav
48
+ aria-label="Member actions"
49
+ className="flex shrink-0 flex-wrap items-center justify-center gap-2"
50
+ >
51
+ {actions.map((action, index) => (
52
+ <a
53
+ key={action.href}
54
+ href={action.href}
55
+ className={index === 0 ? PILL_PRIMARY : PILL}
56
+ >
57
+ {action.label}
58
+ </a>
59
+ ))}
60
+ </nav>
61
+ )}
62
+ </div>
63
+
64
+ <dl className="grid grid-cols-3 gap-2 border-t border-border px-4 py-3 text-center">
65
+ <div>
66
+ <dd className={`text-lg font-bold text-foreground ${NUMERIC}`}>
67
+ {count(postCount)}
68
+ </dd>
69
+ <dt className="text-xs text-muted-foreground">Posts</dt>
70
+ </div>
71
+ <div>
72
+ <dd className="text-sm font-semibold text-foreground">
73
+ <Stamp at={joinedAt} />
74
+ </dd>
75
+ <dt className="text-xs text-muted-foreground">Joined</dt>
76
+ </div>
77
+ <div>
78
+ <dd className="text-sm font-semibold text-foreground">
79
+ {lastVisitAt === null ? 'Never' : <Stamp at={lastVisitAt} />}
80
+ </dd>
81
+ <dt className="text-xs text-muted-foreground">Last visit</dt>
82
+ </div>
83
+ </dl>
84
+ </section>
85
+
86
+ {fields.length > 0 && (
87
+ <section
88
+ aria-labelledby="profile-fields-heading"
89
+ className="rounded-lg border border-border bg-card text-card-foreground shadow-elevation"
90
+ >
91
+ <h2
92
+ id="profile-fields-heading"
93
+ className="px-4 pt-3 pb-1 text-[1.0625rem] font-bold tracking-tight"
94
+ >
95
+ About
96
+ </h2>
97
+ <dl className="grid gap-x-6 gap-y-3 px-4 pt-2 pb-4 sm:grid-cols-2">
98
+ {fields.map((field) => (
99
+ <div key={field.label}>
100
+ <dt className="text-xs text-muted-foreground">{field.label}</dt>
101
+ <dd className="text-sm break-words">{field.value}</dd>
102
+ </div>
103
+ ))}
104
+ </dl>
105
+ </section>
106
+ )}
107
+
108
+ {signatureHtml !== null && (
109
+ <section
110
+ aria-labelledby="profile-signature-heading"
111
+ className="rounded-lg border border-border bg-card text-card-foreground shadow-elevation"
112
+ >
113
+ <h2
114
+ id="profile-signature-heading"
115
+ className="px-4 pt-3 pb-1 text-[1.0625rem] font-bold tracking-tight"
116
+ >
117
+ Signature
118
+ </h2>
119
+ <div
120
+ className="prose-md px-4 pt-2 pb-4 text-sm text-muted-foreground"
121
+ dangerouslySetInnerHTML={{ __html: signatureHtml }}
122
+ />
123
+ </section>
124
+ )}
125
+
126
+ {regions?.plugins}
127
+ </div>
128
+ </div>
129
+ )
130
+ }
@@ -0,0 +1,43 @@
1
+ import type { NavigationModel } from '@meith/theme-kit'
2
+
3
+ import { 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="bg-background">
10
+ <ol
11
+ className={`${PAGE} flex items-center gap-1 overflow-x-auto pt-3 text-sm 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">
17
+ {index > 0 && (
18
+ <span aria-hidden="true" className="select-none opacity-50">
19
+
20
+ </span>
21
+ )}
22
+ {isLast ? (
23
+ <span
24
+ aria-current="page"
25
+ className="max-w-[24ch] truncate font-semibold text-foreground sm:max-w-none"
26
+ >
27
+ {item.label}
28
+ </span>
29
+ ) : (
30
+ <a
31
+ href={item.href}
32
+ className="rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-accent hover:text-foreground"
33
+ >
34
+ {item.label}
35
+ </a>
36
+ )}
37
+ </li>
38
+ )
39
+ })}
40
+ </ol>
41
+ </nav>
42
+ )
43
+ }
@@ -0,0 +1,62 @@
1
+ import { cn } from '@meith/ui'
2
+ import type { NoticeModel } from '@meith/theme-kit'
3
+
4
+ const KIND = {
5
+ info: {
6
+ label: 'Notice',
7
+ className: 'border-primary/30 bg-primary/8 text-foreground',
8
+ mark: 'bg-primary text-primary-foreground',
9
+ },
10
+ success: {
11
+ label: 'Done',
12
+ className: 'border-moderation-approved/30 bg-moderation-approved/8 text-foreground',
13
+ mark: 'bg-moderation-approved text-card',
14
+ },
15
+ warning: {
16
+ label: 'Warning',
17
+ className: 'border-thread-pinned/30 bg-thread-pinned/8 text-foreground',
18
+ mark: 'bg-thread-pinned text-card',
19
+ },
20
+ error: {
21
+ label: 'Error',
22
+ className: 'border-destructive/30 bg-destructive/8 text-foreground',
23
+ mark: 'bg-destructive text-destructive-foreground',
24
+ },
25
+ } as const
26
+
27
+ export function Notice({ kind, message, dismissHref }: NoticeModel) {
28
+ const tone = KIND[kind]
29
+
30
+ return (
31
+ <div
32
+ role={kind === 'error' ? 'alert' : 'status'}
33
+ className={cn(
34
+ 'flex items-center gap-3 rounded-lg border px-3 py-2.5 text-sm shadow-elevation',
35
+ tone.className,
36
+ )}
37
+ >
38
+ <span
39
+ aria-hidden="true"
40
+ className={cn(
41
+ 'inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
42
+ tone.mark,
43
+ )}
44
+ >
45
+ {kind === 'error' || kind === 'warning' ? '!' : 'i'}
46
+ </span>
47
+
48
+ <p className="min-w-0 flex-1">
49
+ <span className="font-semibold">{tone.label}:</span> {message}
50
+ </p>
51
+
52
+ {dismissHref !== null && (
53
+ <a
54
+ href={dismissHref}
55
+ className="shrink-0 rounded-full px-2 py-1 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
56
+ >
57
+ Dismiss
58
+ </a>
59
+ )}
60
+ </div>
61
+ )
62
+ }