@voltro/ui-shadcn 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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +3016 -0
- package/dist/brand.d.ts +73 -0
- package/dist/brand.js +177 -0
- package/dist/cn.d.ts +11 -0
- package/dist/cn.js +6 -0
- package/dist/index.d.ts +1183 -0
- package/dist/index.js +2636 -0
- package/dist/tokens.css +532 -0
- package/package.json +64 -0
- package/src/brand/README.md +60 -0
- package/src/brand/assets/voltro-favicon.svg +30 -0
- package/src/brand/assets/voltro-icon-dark.svg +26 -0
- package/src/brand/assets/voltro-icon.svg +14 -0
- package/src/brand/assets/voltro-mark-mono.svg +5 -0
- package/src/brand/assets/voltro-mark.svg +14 -0
- package/src/brand/voltroLogo.tsx +244 -0
- package/src/cn.ts +10 -0
- package/src/compositions/appShell.tsx +72 -0
- package/src/compositions/codeCompare.tsx +88 -0
- package/src/compositions/docShell.tsx +112 -0
- package/src/compositions/docsLayout.tsx +577 -0
- package/src/compositions/featureBento.tsx +103 -0
- package/src/compositions/featureGrid.tsx +41 -0
- package/src/compositions/heroSection.tsx +55 -0
- package/src/compositions/landingCta.tsx +85 -0
- package/src/compositions/landingHero.tsx +174 -0
- package/src/compositions/landingStats.tsx +99 -0
- package/src/compositions/loginCard.tsx +139 -0
- package/src/compositions/pageHeader.tsx +58 -0
- package/src/compositions/profileMenu.tsx +316 -0
- package/src/compositions/siteFooter.tsx +250 -0
- package/src/compositions/themeToggle.tsx +82 -0
- package/src/cookies.ts +109 -0
- package/src/index.ts +160 -0
- package/src/primitives/animatedNumber.tsx +73 -0
- package/src/primitives/avatar.tsx +39 -0
- package/src/primitives/badge.tsx +39 -0
- package/src/primitives/button.tsx +53 -0
- package/src/primitives/callout.tsx +97 -0
- package/src/primitives/card.tsx +68 -0
- package/src/primitives/checkbox.tsx +55 -0
- package/src/primitives/codeBlock.tsx +134 -0
- package/src/primitives/codeWindow.tsx +84 -0
- package/src/primitives/dialog.tsx +43 -0
- package/src/primitives/docCard.tsx +109 -0
- package/src/primitives/docIcons.tsx +268 -0
- package/src/primitives/dropdownMenu.tsx +162 -0
- package/src/primitives/gridOverlay.tsx +51 -0
- package/src/primitives/highlightedCode.tsx +112 -0
- package/src/primitives/input.tsx +25 -0
- package/src/primitives/label.tsx +19 -0
- package/src/primitives/localeSwitcher.tsx +90 -0
- package/src/primitives/meshBackdrop.tsx +62 -0
- package/src/primitives/scrollReveal.tsx +70 -0
- package/src/primitives/searchModal.tsx +304 -0
- package/src/primitives/select.tsx +24 -0
- package/src/primitives/separator.tsx +24 -0
- package/src/primitives/skeleton.tsx +12 -0
- package/src/primitives/sparkles.tsx +105 -0
- package/src/primitives/steps.tsx +55 -0
- package/src/primitives/tabs.tsx +102 -0
- package/src/primitives/textarea.tsx +24 -0
- package/src/primitives/toast.tsx +44 -0
- package/src/primitives/tocScrollSpy.tsx +110 -0
- package/src/primitives/toggle.tsx +50 -0
- package/src/primitives/toggleGroup.tsx +62 -0
- package/src/tokens.css +532 -0
- package/src/widgets.tsx +297 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// ProfileMenu — circular avatar trigger + Radix-backed DropdownMenu
|
|
2
|
+
// with theme + language switcher, app-specific links, and an optional
|
|
3
|
+
// sign-out action. Dropped into every Voltro-Cloud sibling app's
|
|
4
|
+
// header (dashboard / marketplace / docs) as the rightmost element.
|
|
5
|
+
//
|
|
6
|
+
// Architecture:
|
|
7
|
+
// • Built on the kit's <DropdownMenu> primitive (Radix wrapper) —
|
|
8
|
+
// get focus management, arrow-key nav, portal, escape, click-
|
|
9
|
+
// outside, click-outside-on-overlay-elements for free.
|
|
10
|
+
// • The kit OWNS theme + language switcher rendering. Theme writes
|
|
11
|
+
// the `voltro:theme` cookie (matches `themeBootScript`) and
|
|
12
|
+
// toggles `<html class="dark">` so the change is instant + survives
|
|
13
|
+
// reload via the boot script.
|
|
14
|
+
// • Language writes the `voltro:lang` cookie, then reloads (the SSR
|
|
15
|
+
// re-runs in the new locale) unless `onLanguageChange` returns
|
|
16
|
+
// `false`. Hidden when `languages` is omitted.
|
|
17
|
+
// • Every user-facing string is a prop (`labels`) with English
|
|
18
|
+
// defaults — the consuming app's catalog can localize the menu's
|
|
19
|
+
// own chrome (it IS the language switcher, after all).
|
|
20
|
+
// • Identity is optional. When absent the avatar shows a neutral
|
|
21
|
+
// glyph and the menu omits the identity header — useful on public
|
|
22
|
+
// pages (marketplace / docs) where the visitor is anonymous.
|
|
23
|
+
// • Sign-out is a real form-POST inside a `DropdownMenuItem`'s
|
|
24
|
+
// `onSelect` handler so the browser handles redirects + Set-Cookie.
|
|
25
|
+
// • Sign-in is a regular menu item that navigates.
|
|
26
|
+
|
|
27
|
+
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
|
28
|
+
import { Button } from '../primitives/button'
|
|
29
|
+
import {
|
|
30
|
+
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
|
31
|
+
DropdownMenuItem, DropdownMenuSeparator,
|
|
32
|
+
} from '../primitives/dropdownMenu'
|
|
33
|
+
import { ToggleGroup, ToggleGroupItem } from '../primitives/toggleGroup'
|
|
34
|
+
import {
|
|
35
|
+
THEME_COOKIE, LANG_COOKIE, getCookie, setCookie, deleteCookie, applyTheme,
|
|
36
|
+
type ThemePreference,
|
|
37
|
+
} from '../cookies'
|
|
38
|
+
|
|
39
|
+
const readStoredTheme = (): ThemePreference => {
|
|
40
|
+
const v = getCookie(THEME_COOKIE)
|
|
41
|
+
if (v === 'dark' || v === 'light' || v === 'system') return v
|
|
42
|
+
return 'system'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const persistTheme = (choice: ThemePreference): void => {
|
|
46
|
+
// 'system' deletes the cookie so the next request falls back to
|
|
47
|
+
// server default / matchMedia (matches themeBootScript semantics).
|
|
48
|
+
if (choice === 'system') deleteCookie(THEME_COOKIE)
|
|
49
|
+
else setCookie(THEME_COOKIE, choice)
|
|
50
|
+
applyTheme(choice)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- Public types ----
|
|
54
|
+
|
|
55
|
+
export interface ProfileMenuLink {
|
|
56
|
+
readonly key: string
|
|
57
|
+
readonly label: string
|
|
58
|
+
readonly href: string
|
|
59
|
+
readonly icon?: ReactNode
|
|
60
|
+
readonly external?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ProfileMenuLanguage {
|
|
64
|
+
readonly code: string
|
|
65
|
+
readonly label: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ProfileMenuProps {
|
|
69
|
+
readonly identity?: {
|
|
70
|
+
readonly name: string
|
|
71
|
+
readonly sublabel?: string
|
|
72
|
+
}
|
|
73
|
+
readonly links?: ReadonlyArray<ProfileMenuLink>
|
|
74
|
+
readonly languages?: ReadonlyArray<ProfileMenuLanguage>
|
|
75
|
+
/** Override the cookie-derived language (e.g. when the consumer
|
|
76
|
+
* knows the SSR-resolved value before the cookie round-trips). */
|
|
77
|
+
readonly currentLanguage?: string
|
|
78
|
+
/** Optional hook that fires AFTER the cookie is written. Default
|
|
79
|
+
* behaviour: `window.location.reload()` so the SSR re-runs in the
|
|
80
|
+
* new locale. Return `false` to suppress the reload (useful when
|
|
81
|
+
* a client-side i18n runtime can hot-swap). */
|
|
82
|
+
readonly onLanguageChange?: (code: string) => boolean | void
|
|
83
|
+
/** URL the logout form posts to. Triggers a full page nav. For
|
|
84
|
+
* apps that have a soft-logout RPC + in-place UI swap (no reload),
|
|
85
|
+
* pass `onLogout` instead — it takes precedence over `logoutUrl`. */
|
|
86
|
+
readonly logoutUrl?: string
|
|
87
|
+
/** Callback-style logout — fires when the user picks "Log out". When
|
|
88
|
+
* supplied, replaces the form-post-to-`logoutUrl` flow with a pure
|
|
89
|
+
* JS handler. Use this for soft-logout flows that re-issue
|
|
90
|
+
* subscriptions on the same WebSocket without navigating away. */
|
|
91
|
+
readonly onLogout?: () => void
|
|
92
|
+
readonly signInUrl?: string
|
|
93
|
+
/** User-facing strings — pass values from the consuming app's catalog
|
|
94
|
+
* to localize the menu's own chrome. Defaults are English. */
|
|
95
|
+
readonly labels?: ProfileMenuLabels
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ProfileMenuLabels {
|
|
99
|
+
/** Section label for the theme switch. Default `'Theme'`. */
|
|
100
|
+
readonly theme?: string
|
|
101
|
+
/** Section label for the language switch. Default `'Language'`. */
|
|
102
|
+
readonly language?: string
|
|
103
|
+
/** Sign-out menu item. Default `'Sign out'`. */
|
|
104
|
+
readonly signOut?: string
|
|
105
|
+
/** Sign-in menu item. Default `'Sign in'`. */
|
|
106
|
+
readonly signIn?: string
|
|
107
|
+
/** `aria-label` of the avatar trigger. Default `'Open profile menu'`. */
|
|
108
|
+
readonly openMenu?: string
|
|
109
|
+
/** Labels of the three theme options. Defaults `'System'`/`'Light'`/`'Dark'`. */
|
|
110
|
+
readonly themes?: { readonly system?: string; readonly light?: string; readonly dark?: string }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- Avatar glyph ----
|
|
114
|
+
//
|
|
115
|
+
// Both branches use `text-current` so the glyph inherits whatever
|
|
116
|
+
// color the wrapping <Button> sets — keeps initial + SVG visually
|
|
117
|
+
// indistinguishable across authed / anonymous states.
|
|
118
|
+
|
|
119
|
+
const AvatarGlyph = ({ initial }: { initial?: string }): ReactNode =>
|
|
120
|
+
initial ? (
|
|
121
|
+
<span className="text-xs font-semibold select-none">{initial}</span>
|
|
122
|
+
) : (
|
|
123
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
124
|
+
<circle cx="8" cy="5.5" r="2.5" stroke="currentColor" strokeWidth="1.4" />
|
|
125
|
+
<path
|
|
126
|
+
d="M2.8 13.4c0-2.6 2.3-4.4 5.2-4.4s5.2 1.8 5.2 4.4"
|
|
127
|
+
stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"
|
|
128
|
+
/>
|
|
129
|
+
</svg>
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
// ---- Menu ----
|
|
133
|
+
|
|
134
|
+
const THEME_VALUES: ReadonlyArray<ThemePreference> = ['system', 'light', 'dark']
|
|
135
|
+
|
|
136
|
+
const DEFAULT_THEME_LABELS: Record<ThemePreference, string> = {
|
|
137
|
+
system: 'System',
|
|
138
|
+
light: 'Light',
|
|
139
|
+
dark: 'Dark',
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const navigateTo = (href: string, external?: boolean): void => {
|
|
143
|
+
if (external) window.open(href, '_blank', 'noopener,noreferrer')
|
|
144
|
+
else window.location.assign(href)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const ProfileMenu = ({
|
|
148
|
+
identity, links, languages, currentLanguage, onLanguageChange,
|
|
149
|
+
logoutUrl, onLogout, signInUrl, labels,
|
|
150
|
+
}: ProfileMenuProps): ReactNode => {
|
|
151
|
+
const [theme, setTheme] = useState<ThemePreference | undefined>(undefined)
|
|
152
|
+
// Hydrate language from cookie on mount; the prop overrides if set.
|
|
153
|
+
const [cookieLang, setCookieLang] = useState<string | undefined>(undefined)
|
|
154
|
+
const lang = currentLanguage ?? cookieLang
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
setTheme(readStoredTheme())
|
|
158
|
+
setCookieLang(getCookie(LANG_COOKIE) ?? undefined)
|
|
159
|
+
}, [])
|
|
160
|
+
|
|
161
|
+
const onThemeChange = useCallback((next: string): void => {
|
|
162
|
+
const choice = next as ThemePreference
|
|
163
|
+
setTheme(choice)
|
|
164
|
+
persistTheme(choice)
|
|
165
|
+
}, [])
|
|
166
|
+
|
|
167
|
+
const onLangSelect = useCallback((next: string): void => {
|
|
168
|
+
setCookie(LANG_COOKIE, next)
|
|
169
|
+
setCookieLang(next)
|
|
170
|
+
// Consumer can suppress the reload by returning `false` (e.g.
|
|
171
|
+
// when they have a hot-swap i18n runtime).
|
|
172
|
+
const suppress = onLanguageChange?.(next) === false
|
|
173
|
+
if (!suppress) window.location.reload()
|
|
174
|
+
}, [onLanguageChange])
|
|
175
|
+
|
|
176
|
+
const onLogoutSelect = useCallback((): void => {
|
|
177
|
+
// Callback-style soft-logout wins when present. Lets the consumer
|
|
178
|
+
// run its own in-place teardown (clear cookie/storage, refresh
|
|
179
|
+
// subscriptions on the existing WS, swap UI to signed-out) with
|
|
180
|
+
// no page navigation.
|
|
181
|
+
if (onLogout) {
|
|
182
|
+
onLogout()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!logoutUrl) return
|
|
186
|
+
// Build a real form so the browser handles redirects + Set-Cookie.
|
|
187
|
+
// DropdownMenuItem's onSelect closes the menu before the navigation
|
|
188
|
+
// fires, which is fine — the form is submitted synchronously below.
|
|
189
|
+
const form = document.createElement('form')
|
|
190
|
+
form.method = 'post'
|
|
191
|
+
form.action = logoutUrl
|
|
192
|
+
document.body.appendChild(form)
|
|
193
|
+
form.submit()
|
|
194
|
+
}, [logoutUrl, onLogout])
|
|
195
|
+
|
|
196
|
+
const initial = identity?.name?.trim().charAt(0).toUpperCase()
|
|
197
|
+
const hasLanguages = languages && languages.length > 0
|
|
198
|
+
const hasLinks = links && links.length > 0
|
|
199
|
+
const hasFooterAction = (identity && (logoutUrl || onLogout)) || (!identity && signInUrl)
|
|
200
|
+
const themeLabel = (v: ThemePreference): string =>
|
|
201
|
+
labels?.themes?.[v] ?? DEFAULT_THEME_LABELS[v]
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<DropdownMenu>
|
|
205
|
+
<DropdownMenuTrigger asChild>
|
|
206
|
+
<Button
|
|
207
|
+
type="button"
|
|
208
|
+
variant="outline"
|
|
209
|
+
size="icon"
|
|
210
|
+
aria-label={labels?.openMenu ?? 'Open profile menu'}
|
|
211
|
+
className="rounded-full size-8 text-muted-foreground hover:text-foreground"
|
|
212
|
+
>
|
|
213
|
+
<AvatarGlyph {...(initial ? { initial } : {})} />
|
|
214
|
+
</Button>
|
|
215
|
+
</DropdownMenuTrigger>
|
|
216
|
+
|
|
217
|
+
<DropdownMenuContent align="end" className="w-64">
|
|
218
|
+
{identity ? (
|
|
219
|
+
<>
|
|
220
|
+
<div className="px-2 py-1.5">
|
|
221
|
+
<div className="text-sm font-medium text-foreground truncate">{identity.name}</div>
|
|
222
|
+
{identity.sublabel ? (
|
|
223
|
+
<div className="text-xs text-muted-foreground truncate mt-0.5">
|
|
224
|
+
{identity.sublabel}
|
|
225
|
+
</div>
|
|
226
|
+
) : null}
|
|
227
|
+
</div>
|
|
228
|
+
<DropdownMenuSeparator />
|
|
229
|
+
</>
|
|
230
|
+
) : null}
|
|
231
|
+
|
|
232
|
+
{hasLinks ? (
|
|
233
|
+
<>
|
|
234
|
+
{links.map((link) => (
|
|
235
|
+
<DropdownMenuItem
|
|
236
|
+
key={link.key}
|
|
237
|
+
onSelect={() => navigateTo(link.href, link.external)}
|
|
238
|
+
>
|
|
239
|
+
{link.icon ?? null}
|
|
240
|
+
<span className="flex-1 truncate">{link.label}</span>
|
|
241
|
+
</DropdownMenuItem>
|
|
242
|
+
))}
|
|
243
|
+
<DropdownMenuSeparator />
|
|
244
|
+
</>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
{/* Theme + language rendered as inline <ToggleGroup>s — same
|
|
248
|
+
* shadcn primitive used elsewhere for chip-radio rows. NOT
|
|
249
|
+
* DropdownMenuItems: keyboard nav steps over each ToggleGroup
|
|
250
|
+
* as one focusable region, then ←/→ inside Radix's
|
|
251
|
+
* RovingFocusGroup picks an option. */}
|
|
252
|
+
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
|
253
|
+
<span className="text-[0.65rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
254
|
+
{labels?.theme ?? 'Theme'}
|
|
255
|
+
</span>
|
|
256
|
+
<ToggleGroup
|
|
257
|
+
type="single"
|
|
258
|
+
size="sm"
|
|
259
|
+
variant="outline"
|
|
260
|
+
{...(theme ? { value: theme } : {})}
|
|
261
|
+
onValueChange={(v: string) => { if (v) onThemeChange(v) }}
|
|
262
|
+
>
|
|
263
|
+
{THEME_VALUES.map((value) => (
|
|
264
|
+
<ToggleGroupItem
|
|
265
|
+
key={value}
|
|
266
|
+
value={value}
|
|
267
|
+
aria-label={themeLabel(value)}
|
|
268
|
+
className="h-7 px-2 text-[11px] uppercase tracking-wider"
|
|
269
|
+
>
|
|
270
|
+
{themeLabel(value)}
|
|
271
|
+
</ToggleGroupItem>
|
|
272
|
+
))}
|
|
273
|
+
</ToggleGroup>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{hasLanguages ? (
|
|
277
|
+
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
|
278
|
+
<span className="text-[0.65rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
279
|
+
{labels?.language ?? 'Language'}
|
|
280
|
+
</span>
|
|
281
|
+
<ToggleGroup
|
|
282
|
+
type="single"
|
|
283
|
+
size="sm"
|
|
284
|
+
variant="outline"
|
|
285
|
+
{...(lang ? { value: lang } : {})}
|
|
286
|
+
onValueChange={(v: string) => { if (v) onLangSelect(v) }}
|
|
287
|
+
>
|
|
288
|
+
{languages.map((l) => (
|
|
289
|
+
<ToggleGroupItem
|
|
290
|
+
key={l.code}
|
|
291
|
+
value={l.code}
|
|
292
|
+
className="h-7 px-2 text-[11px] uppercase tracking-wider"
|
|
293
|
+
>
|
|
294
|
+
{l.label}
|
|
295
|
+
</ToggleGroupItem>
|
|
296
|
+
))}
|
|
297
|
+
</ToggleGroup>
|
|
298
|
+
</div>
|
|
299
|
+
) : null}
|
|
300
|
+
|
|
301
|
+
{hasFooterAction ? <DropdownMenuSeparator /> : null}
|
|
302
|
+
|
|
303
|
+
{identity && (logoutUrl || onLogout) ? (
|
|
304
|
+
<DropdownMenuItem destructive onSelect={onLogoutSelect}>
|
|
305
|
+
{labels?.signOut ?? 'Sign out'}
|
|
306
|
+
</DropdownMenuItem>
|
|
307
|
+
) : !identity && signInUrl ? (
|
|
308
|
+
<DropdownMenuItem onSelect={() => navigateTo(signInUrl)}>
|
|
309
|
+
<span className="flex-1">{labels?.signIn ?? 'Sign in'}</span>
|
|
310
|
+
<span aria-hidden="true">→</span>
|
|
311
|
+
</DropdownMenuItem>
|
|
312
|
+
) : null}
|
|
313
|
+
</DropdownMenuContent>
|
|
314
|
+
</DropdownMenu>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// SiteFooter — the canonical public-facing footer for marketing /
|
|
2
|
+
// docs / marketplace pages. NOT used inside the authenticated
|
|
3
|
+
// dashboard (that surface has its own compact app-chrome footer).
|
|
4
|
+
//
|
|
5
|
+
// ┌──────────────────────────────────────────────────────────────┐
|
|
6
|
+
// │ • hairline top highlight (primary tint) │
|
|
7
|
+
// ├──────────────────────────────────────────────────────────────┤
|
|
8
|
+
// │ │
|
|
9
|
+
// │ ┌────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
|
10
|
+
// │ │ brand │ │ Product │ │ Docs │ │ Company │ │
|
|
11
|
+
// │ │ tagline │ │ Demo │ │ Getting… │ │ About │ │
|
|
12
|
+
// │ │ social row │ │ Pricing │ │ Plugins │ │ Blog │ │
|
|
13
|
+
// │ │ │ │ Status │ │ Reference│ │ Careers │ │
|
|
14
|
+
// │ └────────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
|
15
|
+
// │ │
|
|
16
|
+
// ├──────────────────────────────────────────────────────────────┤
|
|
17
|
+
// │ © Year · brand status badge · lang · theme │
|
|
18
|
+
// └──────────────────────────────────────────────────────────────┘
|
|
19
|
+
//
|
|
20
|
+
// Layout: data-driven via `brand`, `tagline`, `social`, `columns`,
|
|
21
|
+
// `bottomLeft`, `bottomRight`. All ReactNodes — apps inject their own
|
|
22
|
+
// router Link via the `LinkComponent` prop (consistent with DocsLayout
|
|
23
|
+
// / DocShell / AppShell pattern).
|
|
24
|
+
|
|
25
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
26
|
+
import { cn } from '../cn'
|
|
27
|
+
import type { ShellLinkProps } from './docShell'
|
|
28
|
+
|
|
29
|
+
export interface SiteFooterLink {
|
|
30
|
+
readonly label: ReactNode
|
|
31
|
+
readonly href: string
|
|
32
|
+
/** External links bypass the internal Link (router) so they navigate
|
|
33
|
+
* via plain <a>. Defaults to detecting `http:`/`https:` prefix in
|
|
34
|
+
* the href. */
|
|
35
|
+
readonly external?: boolean
|
|
36
|
+
/** Small label shown next to the link text (e.g. "new", "beta"). */
|
|
37
|
+
readonly badge?: ReactNode
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SiteFooterColumn {
|
|
41
|
+
/** Section heading — rendered as small uppercase tracking. */
|
|
42
|
+
readonly title: ReactNode
|
|
43
|
+
readonly links: ReadonlyArray<SiteFooterLink>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SiteFooterSocial {
|
|
47
|
+
/** Accessible label, e.g. "GitHub". */
|
|
48
|
+
readonly label: string
|
|
49
|
+
readonly href: string
|
|
50
|
+
readonly icon: ReactNode
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface SiteFooterProps {
|
|
54
|
+
/** Brand mark — usually a small wordmark + Badge. */
|
|
55
|
+
readonly brand: ReactNode
|
|
56
|
+
/** Short paragraph under the brand. ~2 lines of prose. */
|
|
57
|
+
readonly tagline?: ReactNode
|
|
58
|
+
/** Social / external profile links. Rendered as a row of 32×32
|
|
59
|
+
* icon buttons under the tagline. */
|
|
60
|
+
readonly social?: ReadonlyArray<SiteFooterSocial>
|
|
61
|
+
/** The column groups. 3 columns is the visual sweet spot; 4 fits
|
|
62
|
+
* but starts feeling busy. */
|
|
63
|
+
readonly columns: ReadonlyArray<SiteFooterColumn>
|
|
64
|
+
|
|
65
|
+
/** Bottom strip slots. Left typically holds the copyright; right
|
|
66
|
+
* holds status / language / theme toggle. When `bottomLeft` is
|
|
67
|
+
* omitted, a default `© {year} {copyrightHolder}` line renders. */
|
|
68
|
+
readonly bottomLeft?: ReactNode
|
|
69
|
+
readonly bottomRight?: ReactNode
|
|
70
|
+
/** Copyright holder shown in the default bottom-left line (only when
|
|
71
|
+
* `bottomLeft` is not supplied). Default `'Voltro. Built on the
|
|
72
|
+
* framework.'`. Pass your own so the shipped footer isn't Voltro-
|
|
73
|
+
* branded. */
|
|
74
|
+
readonly copyrightHolder?: ReactNode
|
|
75
|
+
/** `aria-label` for the social-links list. Default `'Social links'`. */
|
|
76
|
+
readonly socialLabel?: string
|
|
77
|
+
|
|
78
|
+
/** Optional client-router Link. Same contract as the other kit
|
|
79
|
+
* compositions. Defaults to plain <a>. */
|
|
80
|
+
readonly LinkComponent?: ComponentType<ShellLinkProps>
|
|
81
|
+
|
|
82
|
+
readonly className?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const PlainLink = ({ to, className, children }: ShellLinkProps): ReactNode => (
|
|
86
|
+
<a href={to} className={className}>{children}</a>
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const isSchemed = (href: string): boolean => /^[a-z][a-z0-9+.-]*:\/\//i.test(href)
|
|
90
|
+
|
|
91
|
+
// FooterLink wraps the user's link with the right element: external
|
|
92
|
+
// → plain <a target="_blank">, internal → LinkComponent.
|
|
93
|
+
const FooterLink = ({
|
|
94
|
+
link, Link,
|
|
95
|
+
}: {
|
|
96
|
+
readonly link: SiteFooterLink
|
|
97
|
+
readonly Link: ComponentType<ShellLinkProps>
|
|
98
|
+
}): ReactNode => {
|
|
99
|
+
const external = link.external ?? isSchemed(link.href)
|
|
100
|
+
const baseClass = 'inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors'
|
|
101
|
+
if (external) {
|
|
102
|
+
return (
|
|
103
|
+
<a
|
|
104
|
+
href={link.href}
|
|
105
|
+
target="_blank"
|
|
106
|
+
rel="noopener noreferrer"
|
|
107
|
+
className={baseClass}
|
|
108
|
+
>
|
|
109
|
+
{link.label}
|
|
110
|
+
{link.badge ? <FooterBadge>{link.badge}</FooterBadge> : null}
|
|
111
|
+
</a>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return (
|
|
115
|
+
<Link to={link.href} className={baseClass}>
|
|
116
|
+
{link.label}
|
|
117
|
+
{link.badge ? <FooterBadge>{link.badge}</FooterBadge> : null}
|
|
118
|
+
</Link>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const FooterBadge = ({ children }: { children: ReactNode }): ReactNode => (
|
|
123
|
+
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[0.6rem] uppercase tracking-wider font-semibold bg-primary/10 text-primary">
|
|
124
|
+
{children}
|
|
125
|
+
</span>
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
export const SiteFooter = ({
|
|
129
|
+
brand, tagline, social, columns,
|
|
130
|
+
bottomLeft, bottomRight,
|
|
131
|
+
copyrightHolder = 'Voltro. Built on the framework.',
|
|
132
|
+
socialLabel = 'Social links',
|
|
133
|
+
LinkComponent = PlainLink, className,
|
|
134
|
+
}: SiteFooterProps): ReactNode => {
|
|
135
|
+
const Link = LinkComponent
|
|
136
|
+
const year = new Date().getFullYear()
|
|
137
|
+
// Default copyright when neither slot fills the bottom-left.
|
|
138
|
+
const defaultLeft = (
|
|
139
|
+
<span className="text-xs text-muted-foreground">
|
|
140
|
+
© {year} {copyrightHolder}
|
|
141
|
+
</span>
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<footer
|
|
146
|
+
className={cn(
|
|
147
|
+
'relative border-t border-border',
|
|
148
|
+
// Hairline highlight along the top — same language as bento
|
|
149
|
+
// cells, ties the page chrome together.
|
|
150
|
+
'before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-gradient-to-r before:from-transparent before:via-primary/50 before:to-transparent',
|
|
151
|
+
className,
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
<div className="mx-auto max-w-7xl px-6 py-14 lg:py-16">
|
|
155
|
+
{/* ---- Main grid: brand + columns ---- */}
|
|
156
|
+
<div className="grid grid-cols-2 md:grid-cols-12 gap-8 lg:gap-12">
|
|
157
|
+
{/* Brand block — spans 4 columns on md, full width on mobile */}
|
|
158
|
+
<div className="col-span-2 md:col-span-4 lg:col-span-5">
|
|
159
|
+
<div className="text-base font-semibold text-foreground">{brand}</div>
|
|
160
|
+
{tagline ? (
|
|
161
|
+
<p className="mt-3 text-sm text-muted-foreground leading-relaxed max-w-xs">
|
|
162
|
+
{tagline}
|
|
163
|
+
</p>
|
|
164
|
+
) : null}
|
|
165
|
+
{social && social.length > 0 ? (
|
|
166
|
+
<ul className="mt-5 flex items-center gap-2" aria-label={socialLabel}>
|
|
167
|
+
{social.map((s) => (
|
|
168
|
+
<li key={s.href}>
|
|
169
|
+
<a
|
|
170
|
+
href={s.href}
|
|
171
|
+
target="_blank"
|
|
172
|
+
rel="noopener noreferrer"
|
|
173
|
+
aria-label={s.label}
|
|
174
|
+
className="inline-flex items-center justify-center w-8 h-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-card transition-colors [&_svg]:w-4 [&_svg]:h-4"
|
|
175
|
+
>
|
|
176
|
+
{s.icon}
|
|
177
|
+
</a>
|
|
178
|
+
</li>
|
|
179
|
+
))}
|
|
180
|
+
</ul>
|
|
181
|
+
) : null}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Link columns. Each column takes equal slice of the
|
|
185
|
+
* remaining 8 cols on md (12-col grid). On mobile they
|
|
186
|
+
* stack into the 2-col grid below the brand. */}
|
|
187
|
+
{columns.map((col, i) => (
|
|
188
|
+
<div
|
|
189
|
+
key={i}
|
|
190
|
+
className={cn(
|
|
191
|
+
'col-span-1',
|
|
192
|
+
// 3 cols → md:col-span-2 (so 3 × 2 + brand 4 = 10, leaves room)
|
|
193
|
+
// Use lg:col-span-2 for tighter packing on big screens.
|
|
194
|
+
columns.length === 3 ? 'md:col-span-2 lg:col-span-2' : 'md:col-span-2',
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
<h3 className="text-xs uppercase tracking-[0.08em] text-muted-foreground font-semibold mb-4">
|
|
198
|
+
{col.title}
|
|
199
|
+
</h3>
|
|
200
|
+
<ul className="space-y-2.5">
|
|
201
|
+
{col.links.map((link, j) => (
|
|
202
|
+
<li key={j}>
|
|
203
|
+
<FooterLink link={link} Link={Link} />
|
|
204
|
+
</li>
|
|
205
|
+
))}
|
|
206
|
+
</ul>
|
|
207
|
+
</div>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* ---- Bottom strip ---- */}
|
|
212
|
+
<div className="mt-12 lg:mt-16 pt-6 border-t border-border flex flex-col-reverse sm:flex-row items-start sm:items-center justify-between gap-4">
|
|
213
|
+
<div>{bottomLeft ?? defaultLeft}</div>
|
|
214
|
+
{bottomRight ? <div className="flex items-center gap-3">{bottomRight}</div> : null}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</footer>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- StatusBadge — handy bottom-right slot piece ----
|
|
222
|
+
// Solid green dot + "All systems operational" style label. Drop into
|
|
223
|
+
// `bottomRight` when the app has a real status URL to link to.
|
|
224
|
+
|
|
225
|
+
interface StatusBadgeProps {
|
|
226
|
+
readonly href: string
|
|
227
|
+
readonly label?: ReactNode
|
|
228
|
+
/** `'ok' | 'degraded' | 'down'` — colours the dot accordingly. */
|
|
229
|
+
readonly tone?: 'ok' | 'degraded' | 'down'
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const STATUS_DOT: Record<NonNullable<StatusBadgeProps['tone']>, string> = {
|
|
233
|
+
ok: 'bg-success shadow-[0_0_8px_2px_oklch(0.7_0.15_162_/_0.5)]',
|
|
234
|
+
degraded: 'bg-warning shadow-[0_0_8px_2px_oklch(0.78_0.18_70_/_0.5)]',
|
|
235
|
+
down: 'bg-destructive shadow-[0_0_8px_2px_oklch(0.7_0.19_22_/_0.5)]',
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export const StatusBadge = ({
|
|
239
|
+
href, label = 'All systems operational', tone = 'ok',
|
|
240
|
+
}: StatusBadgeProps): ReactNode => (
|
|
241
|
+
<a
|
|
242
|
+
href={href}
|
|
243
|
+
target="_blank"
|
|
244
|
+
rel="noopener noreferrer"
|
|
245
|
+
className="inline-flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
246
|
+
>
|
|
247
|
+
<span className={cn('w-1.5 h-1.5 rounded-full motion-safe:animate-pulse', STATUS_DOT[tone])} aria-hidden="true" />
|
|
248
|
+
{label}
|
|
249
|
+
</a>
|
|
250
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ThemeToggle — flips the `.dark` class on <html>. Persists choice
|
|
2
|
+
// to the `voltro:theme` cookie so the server can bake the correct
|
|
3
|
+
// class on the next request (and `themeBootScript` can sync the
|
|
4
|
+
// initial paint without round-tripping localStorage).
|
|
5
|
+
//
|
|
6
|
+
// Component is interactive — when used inside a static page, mark
|
|
7
|
+
// the surrounding island as `interaction` strategy.
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
|
|
10
|
+
import { Button } from '../primitives/button'
|
|
11
|
+
import { cn } from '../cn'
|
|
12
|
+
import { THEME_COOKIE, setCookie } from '../cookies'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Inline script to drop into <head>. Reads the `voltro:theme` cookie
|
|
16
|
+
* (set by ThemeToggle / ProfileMenu) BEFORE React hydrates so the
|
|
17
|
+
* initial paint matches — no FOUC even when the server hasn't yet
|
|
18
|
+
* baked the cookie-derived class.
|
|
19
|
+
*
|
|
20
|
+
* <script dangerouslySetInnerHTML={{ __html: themeBootScript() }} />
|
|
21
|
+
*
|
|
22
|
+
* Cookie wins over matchMedia: 'system' (or absent) falls back to
|
|
23
|
+
* the OS preference; 'light' / 'dark' are absolute.
|
|
24
|
+
*/
|
|
25
|
+
export const themeBootScript = (): string => `
|
|
26
|
+
(function(){
|
|
27
|
+
try {
|
|
28
|
+
var rx = new RegExp('(?:^|;\\\\s*)${THEME_COOKIE.replace(/[.*+?^${}()|[\]\\]/g, '\\\\$&')}=([^;]*)');
|
|
29
|
+
var m = document.cookie.match(rx);
|
|
30
|
+
var saved = m ? decodeURIComponent(m[1]) : '';
|
|
31
|
+
var dark =
|
|
32
|
+
saved === 'dark' ||
|
|
33
|
+
(saved !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
|
34
|
+
document.documentElement.classList.toggle('dark', dark);
|
|
35
|
+
} catch (_) { /* should be unreachable */ }
|
|
36
|
+
})();
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
interface ThemeToggleProps extends HTMLAttributes<HTMLButtonElement> {
|
|
40
|
+
readonly lightLabel?: ReactNode
|
|
41
|
+
readonly darkLabel?: ReactNode
|
|
42
|
+
/** `aria-label` while dark is active (the action = go light).
|
|
43
|
+
* Default `'Switch to light theme'` — pass a catalog value to localize. */
|
|
44
|
+
readonly switchToLightLabel?: string
|
|
45
|
+
/** `aria-label` while light is active (the action = go dark).
|
|
46
|
+
* Default `'Switch to dark theme'` — pass a catalog value to localize. */
|
|
47
|
+
readonly switchToDarkLabel?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ThemeToggle = ({
|
|
51
|
+
className, lightLabel = '☀', darkLabel = '☾',
|
|
52
|
+
switchToLightLabel = 'Switch to light theme',
|
|
53
|
+
switchToDarkLabel = 'Switch to dark theme',
|
|
54
|
+
...props
|
|
55
|
+
}: ThemeToggleProps): ReactNode => {
|
|
56
|
+
const [isDark, setIsDark] = useState<boolean | undefined>(undefined)
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
setIsDark(document.documentElement.classList.contains('dark'))
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
const toggle = (): void => {
|
|
63
|
+
const next = !document.documentElement.classList.contains('dark')
|
|
64
|
+
document.documentElement.classList.toggle('dark', next)
|
|
65
|
+
setCookie(THEME_COOKIE, next ? 'dark' : 'light')
|
|
66
|
+
setIsDark(next)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Button
|
|
71
|
+
type="button"
|
|
72
|
+
variant="ghost"
|
|
73
|
+
size="icon"
|
|
74
|
+
onClick={toggle}
|
|
75
|
+
className={cn(className)}
|
|
76
|
+
aria-label={isDark ? switchToLightLabel : switchToDarkLabel}
|
|
77
|
+
{...props}
|
|
78
|
+
>
|
|
79
|
+
{isDark === undefined ? null : isDark ? lightLabel : darkLabel}
|
|
80
|
+
</Button>
|
|
81
|
+
)
|
|
82
|
+
}
|