Files

218 lines
15 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// Offcanvas behavior script lives in BaseLayout (after global scripts).
import FormText from '@ui/FormText.astro'
import Icon from '@ui/Icon.astro'
import Illustration from '@ui/Illustration.astro'
import Subheader from '@ui/Subheader.astro'
import { site } from '@shared/lib/site'
import { themeDefaults, themeSections, type ThemeKey, type ThemePreset } from '@shared/lib/theme-config'
const lastSection = themeSections.length - 1
// Thumbnails, drawn as inline SVG on the theme's own custom properties so they
// follow the color mode, the gray base and the accent color. The preset tiles
// pin light and dark on purpose (they depict a mode); the layout tiles follow
// the page. All share one 96×64 frame.
type Mode = 'light' | 'dark'
const shade: Record<Mode, string> = { light: 'var(--tblr-gray-50)', dark: 'var(--tblr-gray-900)' }
const bar: Record<Mode | 'primary', string> = { light: 'var(--tblr-white)', dark: 'var(--tblr-gray-800)', primary: 'var(--tblr-primary)' }
const lineOn: Record<Mode, string> = { light: 'var(--tblr-gray-300)', dark: 'var(--tblr-gray-700)' }
// mode-aware palette for the layout tiles
const page = 'var(--tblr-bg-surface-secondary)'
const surface = 'var(--tblr-bg-surface)'
const border = 'var(--tblr-border-color)'
const primary = 'var(--tblr-primary)'
const svg = (inner: string, attrs = '') => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 64" class="form-imagecheck-image w-100" aria-hidden="true" focusable="false"${attrs}>${inner}</svg>`
const frame = (fill: string) => `<rect x="0.5" y="0.5" width="95" height="63" rx="6" fill="${fill}" stroke="${border}" />`
const outline = `<rect x="0.5" y="0.5" width="95" height="63" rx="6" fill="none" stroke="${border}" />`
const topBar = (fill: string) => `<path d="M6.5 0.5h83a6 6 0 0 1 6 6v13.5h-95v-13.5a6 6 0 0 1 6 -6z" fill="${fill}" /><rect x="0.5" y="19.5" width="95" height="0.5" fill="${border}" />`
const rail = (w: number, fill: string) => `<path d="M6.5 0.5h${w - 6}v63h-${w - 6}a6 6 0 0 1 -6 -6v-51a6 6 0 0 1 6 -6z" fill="${fill}" /><rect x="${w}" y="0.5" width="0.5" height="63" fill="${border}" />`
const lines = (x: number, y: number, widths: number[], fill: string, step = 11) => widths.map((w, i) => `<rect x="${x}" y="${y + i * step}" width="${w}" height="5" rx="2.5" fill="${fill}" />`).join('')
const dots = (x: number, y: number, count: number, fill: string) => Array.from({ length: count }, (_, i) => `<circle cx="${x}" cy="${y + i * 11}" r="2.5" fill="${fill}" />`).join('')
const pageHalf = (mode: Mode, right: boolean) => (right ? `<path d="M48 0.5h41.5a6 6 0 0 1 6 6v51a6 6 0 0 1 -6 6h-41.5z" fill="${shade[mode]}" />` : `<path d="M6.5 0.5h41.5v63h-41.5a6 6 0 0 1 -6 -6v-51a6 6 0 0 1 6 -6z" fill="${shade[mode]}" />`)
const barHalf = (fill: string, right: boolean) => (right ? `<path d="M48 0.5h41.5a6 6 0 0 1 6 6v13.5h-47.5z" fill="${fill}" />` : `<path d="M6.5 0.5h41.5v19.5h-47.5v-13.5a6 6 0 0 1 6 -6z" fill="${fill}" />`)
/** A preset tile: page halves and navigation bar halves in the preset's modes. */
const presetSvg = (values: Partial<Record<ThemeKey, string>>) => {
const theme = values.theme ?? 'light'
const nav = values['navbar-theme'] ?? 'default'
const [first, second]: [Mode, Mode] = theme === 'auto' ? ['light', 'dark'] : [theme as Mode, theme as Mode]
const barFill = (mode: Mode) => (nav === 'primary' ? bar.primary : nav === 'dark' ? bar.dark : bar[mode])
return svg(pageHalf(first, false) + pageHalf(second, true) + barHalf(barFill(first), false) + barHalf(barFill(second), true) + lines(12, 30, [46, 72, 30], lineOn[first]) + outline)
}
/** Marks drawn inside an accent-colored navigation shape. */
const onAccent = 'rgba(255, 255, 255, 0.7)'
/** A content card: the page's own surface with a border, lines inside. */
const card = (x: number, y: number, w: number, h: number) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="3" fill="${surface}" stroke="${border}" />` + lines(x + 6, y + 7, [w * 0.6, w * 0.8, w * 0.4].map(Math.round), border, 9)
/** A scrollbar thumb on the right edge, at the top or scrolled down. */
const thumb = (y: number) => `<rect x="90" y="${y}" width="3" height="14" rx="1.5" fill="${border}" />`
/** A tile for one value of one setting. */
const tileSvg = (key: string, value: string): string => {
switch (key) {
case 'navbar-position':
// The navigation is the accent-colored shape: a bar across the top or a rail down the start side.
return value === 'vertical'
? svg(frame(page) + rail(30, primary) + lines(7, 10, [16, 16, 16, 16], onAccent, 10) + lines(40, 12, [40, 48, 28], border) + outline)
: svg(frame(page) + topBar(primary) + [8, 26, 44].map((x) => `<rect x="${x}" y="8" width="14" height="4" rx="2" fill="${onAccent}" />`).join('') + lines(12, 30, [46, 72, 30], border) + outline)
case 'layout':
// Only the content width changes, so the navigation stays out of these tiles.
// The surround is the inverted surface, so it stays dark on a light page and light on a dark one.
if (value === 'boxed') return svg(`<rect x="0.5" y="0.5" width="95" height="63" rx="6" fill="var(--tblr-bg-surface-inverted)" /><rect x="12" y="8" width="72" height="48" rx="4" fill="${page}" />` + card(18, 16, 60, 32) + outline)
return value === 'fluid' ? svg(frame(page) + card(4, 8, 88, 48) + outline) : svg(frame(page) + card(22, 8, 52, 48) + outline)
case 'navbar':
// Sticky: the bar keeps its place while the content has scrolled up behind it.
return value === 'sticky'
? svg(frame(page) + lines(12, 4, [46, 72, 30, 60], border) + thumb(44) + `<rect x="0.5" y="19.5" width="95" height="5" fill="var(--tblr-gray-900)" opacity="0.18" />` + topBar(primary) + outline)
: svg(frame(page) + topBar(primary) + lines(12, 30, [46, 72, 30], border) + thumb(24) + outline)
case 'navbar-style':
// Floating: the bar keeps a gap from the edges and gets its own corners.
return value === 'floating'
? svg(frame(page) + `<rect x="5" y="5" width="86" height="14" rx="4" fill="${primary}" />` + [11, 29, 47].map((x) => `<rect x="${x}" y="10" width="14" height="4" rx="2" fill="${onAccent}" />`).join('') + lines(12, 30, [46, 72, 30], border) + outline)
: svg(frame(page) + topBar(primary) + [8, 26, 44].map((x) => `<rect x="${x}" y="8" width="14" height="4" rx="2" fill="${onAccent}" />`).join('') + lines(12, 30, [46, 72, 30], border) + outline)
case 'offcanvas-style':
// A panel open on the end side, over the dimmed page: flush with the edges, or a card with a gap.
return value === 'floating'
? svg(frame(page) + lines(10, 12, [40, 48, 28], border) + `<rect x="0.5" y="0.5" width="95" height="63" rx="6" fill="var(--tblr-gray-900)" opacity="0.18" /><rect x="53" y="5" width="38" height="54" rx="4" fill="${surface}" stroke="${border}" />` + lines(59, 12, [20, 26, 14], border) + outline)
: svg(frame(page) + lines(10, 12, [40, 48, 28], border) + `<rect x="0.5" y="0.5" width="95" height="63" rx="6" fill="var(--tblr-gray-900)" opacity="0.18" /><path d="M58 0.5h31.5a6 6 0 0 1 6 6v51a6 6 0 0 1 -6 6h-31.5z" fill="${surface}" /><rect x="57.5" y="0.5" width="0.5" height="63" fill="${border}" />` + lines(64, 12, [20, 26, 14], border) + outline)
case 'sidebar':
if (value === 'folded') return svg(frame(page) + rail(16, primary) + dots(8, 12, 4, onAccent) + lines(26, 12, [50, 60, 34], border) + outline)
if (value === 'folded-hover') return svg(frame(page) + rail(16, primary) + dots(8, 12, 4, onAccent) + lines(26, 12, [50, 60, 34], border) + `<rect x="16" y="0.5" width="4" height="63" fill="var(--tblr-gray-900)" opacity="0.12" /><rect x="16.5" y="0.5" width="28" height="63" fill="${surface}" stroke="${border}" />` + lines(21, 9.5, [18, 18, 18, 18], border, 11) + outline)
return svg(frame(page) + rail(30, primary) + lines(7, 10, [16, 16, 16, 16], onAccent, 10) + lines(40, 12, [40, 48, 28], border) + outline)
case 'theme-base':
// The base's own gray scale: the attribute swaps the --tblr-gray-* values for this tile only.
return svg(frame(surface) + [100, 300, 500, 700, 900].map((shadeValue, i) => `<rect x="${10 + i * 16}" y="24" width="12" height="16" rx="3" fill="var(--tblr-gray-${shadeValue})" />`).join(''), ` data-bs-theme-base="${value}"`)
case 'theme-radius': {
// One shape carries the whole message: a square at 0, a circle at 2.
const r = Number(value) * 10
return svg(frame(page) + `<rect x="28" y="12" width="40" height="40" rx="${r}" fill="${primary}" />`)
}
default:
return svg(frame(page) + outline)
}
}
---
<!-- BEGIN THEME SETTINGS -->
<div class="settings">
<button type="button" class="btn btn-floating btn-icon btn-primary" data-bs-toggle="offcanvas" data-bs-target="#offcanvas-settings" aria-controls="offcanvas-settings" aria-label="Customize">
<Icon name="brush" />
</button>
<form class="offcanvas offcanvas-start offcanvas-floating" tabindex="-1" id="offcanvas-settings" role="dialog" aria-modal="true" aria-labelledby="offcanvas-settings-title">
<button type="button" class="btn-close position-absolute top-0 end-0 m-3 z-1" data-bs-dismiss="offcanvas" aria-label="Close"></button>
{/* The body is the only scrolling area, so the actions below stay reachable. */}
<div class="offcanvas-body p-0">
<section class="p-4 text-center border-bottom">
<Illustration image="computer-fix" height={120} class="mb-3" />
<h2 class="offcanvas-title mb-1" id="offcanvas-settings-title">Make {site.title} your own</h2>
<p class="text-secondary m-0">Pick the look and layout of this preview. Your choices are saved in this browser.</p>
</section>
{
themeSections.map((section, index) => (
<section class:list={['p-4', index < lastSection && 'border-bottom']}>
<Subheader as="h3" class="mb-3">
{section.title}
</Subheader>
<div class="space-y-4">
{section.settings.map((setting) => (
<div role="group" aria-labelledby={`theme-setting-${setting.key}`} data-theme-setting={setting.key}>
<div class="form-label mb-1" id={`theme-setting-${setting.key}`}>
{setting.legend}
</div>
<FormText class="mb-3">
{setting.hint}
</FormText>
{setting.control === 'color' ? (
<div class="row g-2">
{setting.options.map((option) => (
<div class="col-auto">
<label class="form-colorinput">
<input type="radio" name={setting.key} value={option.value} class="form-colorinput-input" checked={option.value === themeDefaults[setting.key as ThemeKey]} aria-label={option.label} />
<span class={`form-colorinput-color bg-${option.value}`} />
</label>
</div>
))}
</div>
) : setting.control === 'font' ? (
<div class="row g-3">
{setting.options.map((option) => (
<div class="col-4">
{/* Block, unlike the image tiles: text does not size the label the way an image does. */}
<label class="form-imagecheck d-block">
<input type="radio" name={setting.key} value={option.value} class="form-imagecheck-input" checked={option.value === themeDefaults[setting.key as ThemeKey]} />
<span class="form-imagecheck-figure p-1">
<span class={`form-imagecheck-image text-center py-2 display-5 fw-medium lh-1 font-${option.value}`} aria-hidden="true">
Aa
</span>
<span class="form-imagecheck-caption">{option.label}</span>
</span>
</label>
</div>
))}
</div>
) : setting.control === 'tile' ? (
<div class="row g-3">
{setting.options.map((option) => (
<div class="col-4">
<label class="form-imagecheck">
<input type="radio" name={setting.key} value={option.value} class="form-imagecheck-input" checked={option.value === themeDefaults[setting.key as ThemeKey]} />
<span class="form-imagecheck-figure p-1">
<Fragment set:html={tileSvg(setting.key, option.value)} />
<span class="form-imagecheck-caption">{option.label}</span>
</span>
</label>
</div>
))}
</div>
) : setting.control === 'preset' ? (
<div class="row g-3">
{(setting.options as ThemePreset[]).map((option) => (
<div class="col-4">
<label class="form-imagecheck">
<input type="radio" name={setting.key} value={option.value} class="form-imagecheck-input" checked={Object.entries(option.values).every(([key, value]) => themeDefaults[key as ThemeKey] === value)} />
<span class="form-imagecheck-figure p-1">
<Fragment set:html={presetSvg(option.values)} />
<span class="form-imagecheck-caption">{option.label}</span>
</span>
</label>
</div>
))}
</div>
) : (
<div class="form-selectgroup">
{setting.options.map((option) => (
<label class="form-selectgroup-item">
<input type="radio" name={setting.key} value={option.value} class="form-selectgroup-input" checked={option.value === themeDefaults[setting.key as ThemeKey]} />
<span class="form-selectgroup-label">{option.label}</span>
</label>
))}
</div>
)}
</div>
))}
</div>
</section>
))
}
</div>
<div class="offcanvas-footer border-top space-y-2">
<button type="button" class="btn w-100" id="reset-changes">
<Icon name="rotate" />
Reset changes
</button>
<button type="button" class="btn btn-primary w-100" data-bs-dismiss="offcanvas">Save</button>
</div>
</form>
</div>
<!-- END THEME SETTINGS -->