Files
lanspread/design/design_reference/components.jsx
T
ddidderr b169a05c31 docs(design): redesign game-folder button as icon + label + dot
The previous design squeezed the full game-directory path into the
top-bar button as truncated `ui-monospace` (e.g.
`…s/Desktop/eti_games_AFTER_LAN_2025`). In practice the leading-ellipsis
truncation rarely showed the meaningful part of the path on real-world
configurations, ate horizontal space the new 3-zone top bar needs for
its actual primary controls, and competed with the filter / search /
sort cluster for attention.

Replace the inline path with an icon + short label + colored status
dot. The full path moves into the tooltip and `aria-label`, where it's
still one mouseover (and screen-reader friendly) away. The button now
communicates the *state* of the configuration at a glance — which is
what users actually need.

Two visual states, both 36 px tall with the same surface as the other
top-bar controls:

- **Set & valid** — label `Game folder`, green dot (`--ok`) with a
  soft glow, default border, tooltip = full path.
- **Not set / invalid** — label `Set game folder`, red dot (`--danger`)
  with a soft glow, a red-tinted border, and a faint red wash on
  hover so the bad state reads as "this is what you need to fix".
  Tooltip = `Please select a game folder`.

"Invalid" (a path is stored but doesn't exist on disk) is collapsed
into the same visual state as "not set" — the user's required action
is identical (open the picker, pick a folder), so a third state
isn't worth the visual budget yet. If we later want to surface a
*last-known* path so the user can re-attach an external drive,
introduce a distinct missing state then.

Implementation notes:

- `DirectoryButton` now takes a single `path: string | null` prop and
  picks state from `!!(path && path.trim())`. Children are
  `Icon.folder`, the label, and an 8 px `.dirbtn-status-dot` sibling
  — the dot is an inline flex sibling, not a corner badge, because
  the button is now wider than tall and a corner pin would feel
  misplaced.
- `.dirbtn` is `inline-flex` with `padding: 0 14px 0 12px`, gap 8 px,
  `white-space: nowrap`, and `flex-shrink: 0`. The `max-width: 360px`
  cap from the path-truncation era is gone — the button is now
  intrinsically sized.
- Dot glow uses `box-shadow: 0 0 6px color-mix(...)` so it still
  reads through the launcher's translucent top-bar background.
- The Tweaks panel grows a dev-only `Game folder set` toggle (under
  the new *Library* section) that flips a `gameFolderSet` flag wired
  into the Launcher, so reviewers can see both states without
  fiddling with real filesystem state. The README explicitly calls
  this out as **dev-only** — production state comes from the
  settings store, not a user-facing toggle.

The README gains a new *Game-folder button* section with the full
spec, a state table, and a rationale paragraph; the "Changes since v2"
list and the interactions list are updated to reflect the new label
and behavior.

Test Plan

- Open `design_reference/SoftLAN Launcher.html` and locate the Tweaks
  panel's *Library → Game folder set* toggle.
  - With the toggle **on**: the top-bar button shows `Game folder`
    with a green dot; hovering the button reveals the full mock path
    in the native tooltip.
  - With the toggle **off**: the label switches to `Set game folder`,
    the dot turns red, the border picks up a red tint, and hovering
    the button reveals `Please select a game folder`.
- Inspect the button with a screen reader / DevTools accessibility
  pane: the `aria-label` should read `Game folder: <path>` when set
  and `Set game folder` when unset.
2026-05-21 21:32:28 +02:00

617 lines
31 KiB
React
Raw 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.
// components.jsx — UI building blocks for the SoftLAN launcher
// Loaded after data.jsx; relies on GAMES/STATE_META/ACTION_FOR_STATE/etc. on window.
const { useState, useMemo, useRef, useEffect } = React;
// ────────────────────────────────────────────────────────────────────
// Iconography (tiny inline SVGs; no emoji)
// ────────────────────────────────────────────────────────────────────
const Icon = {
search: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="7" cy="7" r="5"/><path d="m13.5 13.5-3-3"/></svg>,
play: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" {...p}><path d="M4 2.5v11l10-5.5z"/></svg>,
server: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="2" y="3" width="12" height="4.5" rx="1"/><rect x="2" y="8.5" width="12" height="4.5" rx="1"/><circle cx="4.6" cy="5.25" r=".55" fill="currentColor" stroke="none"/><circle cx="4.6" cy="10.75" r=".55" fill="currentColor" stroke="none"/><path d="M7 5.25h4.5M7 10.75h4.5"/></svg>,
install:(p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 12.5h11"/></svg>,
download:(p)=> <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M8 2v8"/><path d="m4.5 7 3.5 3.5L11.5 7"/><path d="M2.5 13.5h11"/></svg>,
folder: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" {...p}><path d="M1.75 3.75v8.5a1 1 0 0 0 1 1h10.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1H7.5L6 2.75H2.75a1 1 0 0 0-1 1z"/></svg>,
kebab: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" {...p}><circle cx="8" cy="3.2" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="8" cy="12.8" r="1.4"/></svg>,
sort: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4h10"/><path d="M4.5 8h7"/><path d="M6 12h4"/></svg>,
users: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="6" cy="6" r="2.4"/><path d="M2 13c.6-2.2 2.2-3.4 4-3.4S9.4 10.8 10 13"/><circle cx="11.2" cy="5.4" r="1.8"/><path d="M10.4 9.8c1.7 0 3 1 3.6 2.6"/></svg>,
close: (p) => <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...p}><path d="m4 4 8 8M12 4l-8 8"/></svg>,
check: (p) => <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m3 8 3.5 3.5L13 5"/></svg>,
chevron:(p) => <svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="m4 6 4 4 4-4"/></svg>,
trash: (p) => <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 4.5h10"/><path d="M5.5 4.5V3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1.5"/><path d="M4.5 4.5 5 13a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-8.5"/></svg>,
};
// ────────────────────────────────────────────────────────────────────
// Cover art — stylized box-art placeholder
// ────────────────────────────────────────────────────────────────────
function GameCover({ game, aspect = 'box', size = 'normal' }) {
const { c1, c2, accent } = game.cover;
// Pick title sizing — shrink for longer names; line-clamp:2 handles wrap
const title = game.title;
const len = title.length;
const fontPx = aspect === 'banner' ? (len > 22 ? 18 : len > 14 ? 22 : 28)
: aspect === 'square' ? (len > 22 ? 18 : len > 14 ? 22 : 28)
: (len > 26 ? 15 : len > 20 ? 17 : len > 14 ? 21 : 26);
// Stable but varied per-game accent shape (id hash → angle / size)
const h = [...game.id].reduce((a, c) => a + c.charCodeAt(0), 0);
const angle = 110 + (h % 60); // 110-170
const blobX = 60 + (h % 30);
const blobY = 10 + ((h * 7) % 30);
return (
<div className="cover" data-aspect={aspect}>
{/* base gradient */}
<div className="cover-base" style={{
background: `linear-gradient(${angle}deg, ${c1} 0%, ${c2} 100%)`,
}}/>
{/* radial accent blob */}
<div className="cover-blob" style={{
background: `radial-gradient(ellipse at ${blobX}% ${blobY}%, ${accent}38, transparent 55%)`,
}}/>
{/* scanline / grain */}
<div className="cover-grain"/>
{/* faint geometric mark */}
<svg className="cover-mark" viewBox="0 0 100 100" preserveAspectRatio="xMaxYMax slice" aria-hidden="true">
<path d={`M ${100 - (h%30)} ${100 - (h%20)} L 100 ${60 + (h%25)} L 100 100 Z`}
fill={accent} fillOpacity="0.12"/>
<circle cx={(h*3)%100} cy={(h*5)%100} r="0.6" fill={accent} fillOpacity="0.4"/>
</svg>
{/* title */}
<div className="cover-titlewrap">
<div className="cover-title" style={{ fontSize: fontPx, textShadow: `0 4px 16px ${c2}aa, 0 1px 0 rgba(0,0,0,.3)` }}>
{title}
</div>
</div>
{/* bottom darkening */}
<div className="cover-vignette"/>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// State chip (corner of cover)
// ────────────────────────────────────────────────────────────────────
function StateChip({ state }) {
const meta = STATE_META[state];
if (!meta || !meta.label) return null;
return (
<div className="state-chip" data-state={state}>
<span className="state-dot" style={{ background: meta.dot }}/>
{meta.label}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Action button — Play / Install / Download / [Downloading progress]
// ────────────────────────────────────────────────────────────────────
function ActionButton({ state, accent, size = 'md', onClick, full = false, game }) {
if (state === 'downloading' && game) {
return <DownloadProgress game={game} accent={accent} size={size} full={full}/>;
}
const action = ACTION_FOR_STATE[state];
const cls = `act-btn act-${action.kind} ${size === 'lg' ? 'act-lg' : ''} ${full ? 'act-full' : ''}`;
const icon = action.kind === 'play' ? <Icon.play/>
: action.kind === 'install' ? <Icon.install/>
: <Icon.download/>;
return (
<button className={cls} onClick={(e) => { e.stopPropagation(); onClick && onClick(); }}
style={action.kind === 'install' ? { background: accent } : undefined}>
{icon}<span>{action.label}</span>
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Live download progress — animates from initial progress upward,
// jitters speed slightly. Two layouts:
// md → card tile. Container-query: shows %+speed, falls back to % only.
// lg → detail overlay. Two-line stats + percentage on the right.
// ────────────────────────────────────────────────────────────────────
function useLiveDownload(game) {
const [p, setP] = useState(game.progress ?? 0.1);
const [speed, setSpeed] = useState(game.speed ?? 30);
useEffect(() => {
const id = setInterval(() => {
setP(prev => {
// step proportional to current speed so smaller games finish faster
const stepGb = (speed / 1024) * 0.6; // 600ms tick
const next = prev + stepGb / game.size;
return next >= 0.985 ? (game.progress ?? 0.1) : next;
});
setSpeed(prev => {
const target = (game.speed ?? 30);
const drift = (Math.random() - 0.5) * 6;
return Math.max(2, target * 0.85 + prev * 0.15 + drift);
});
}, 600);
return () => clearInterval(id);
}, [game.id, game.size, game.speed, game.progress]);
return { progress: p, speed };
}
function DownloadProgress({ game, accent, size = 'md', full = false }) {
const { progress, speed } = useLiveDownload(game);
const pct = Math.min(99, Math.round(progress * 100));
const downloadedGb = game.size * progress;
const remainingGb = Math.max(0, game.size - downloadedGb);
const etaSec = remainingGb * 1024 / Math.max(speed, 0.1);
const isLg = size === 'lg';
const onCancel = (e) => { e.stopPropagation(); /* mock */ };
if (isLg) {
return (
<div className={`dl dl-lg ${full ? 'dl-full' : ''}`}
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct}
aria-label={`Downloading ${game.title}`}>
<div className="dl-fill" style={{ width: `${pct}%` }}/>
<div className="dl-lg-grid">
<div className="dl-lg-primary">
<span className="dl-pulse" aria-hidden="true"/>
<span className="dl-label">Downloading</span>
</div>
<div className="dl-lg-secondary">
<span className="dl-bytes">
<strong>{fmtBytes(downloadedGb)}</strong>
<span className="dl-of"> / {fmtBytes(game.size)}</span>
</span>
<span className="dl-sep">·</span>
<span className="dl-speed">{fmtSpeed(speed)}</span>
{game.peers > 0 && (
<React.Fragment>
<span className="dl-sep dl-sep-peers">·</span>
<span className="dl-peers" title={`Downloading from ${game.peers} ${game.peers === 1 ? 'peer' : 'peers'} on the LAN`}>
<Icon.users/>
<span>{game.peers}</span>
</span>
</React.Fragment>
)}
<span className="dl-sep dl-sep-eta">·</span>
<span className="dl-eta">{fmtEta(etaSec)} left</span>
</div>
<div className="dl-lg-pct">{pct}<span className="dl-pct-sym">%</span></div>
<button className="dl-cancel" onClick={onCancel} aria-label="Cancel download">
<Icon.close/>
</button>
</div>
</div>
);
}
return (
<div className={`dl dl-md ${full ? 'dl-full' : ''}`}
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct}
aria-label={`Downloading ${game.title}`}
title={`${pct}% · ${fmtSpeed(speed)} · ${fmtEta(etaSec)} left`}>
<div className="dl-fill" style={{ width: `${pct}%` }}/>
<div className="dl-md-row">
<span className="dl-pct">
<span className="dl-pulse" aria-hidden="true"/>
{pct}<span className="dl-pct-sym">%</span>
</span>
<span className="dl-speed">{fmtSpeedShort(speed)}</span>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Game card
// ────────────────────────────────────────────────────────────────────
function GameCard({ game, accent, aspect, onOpen }) {
return (
<article className="card" onClick={() => onOpen && onOpen(game)} tabIndex={0}>
<div className="card-cover-wrap" data-aspect={aspect}>
<GameCover game={game} aspect={aspect}/>
<StateChip state={game.state}/>
<div className="card-mp" title={`${game.players} players`}>
<Icon.users/><span>{game.players}</span>
</div>
</div>
<div className="card-body">
<div className="card-title" title={game.title}>{game.title}</div>
<div className="card-meta">
<span>{fmtSize(game.size)}</span>
<span className="card-dot">·</span>
<span>{game.tags[0]}</span>
</div>
<ActionButton state={game.state} accent={accent} full game={game}/>
</div>
</article>
);
}
// ────────────────────────────────────────────────────────────────────
// Filter controls
// ────────────────────────────────────────────────────────────────────
const FILTER_TABS = [
{ key: 'all', label: 'All Games' },
{ key: 'local', label: 'Local' },
{ key: 'installed', label: 'Installed' },
];
function SegmentedFilters({ value, onChange, counts, accent }) {
const ref = useRef(null);
const [thumb, setThumb] = useState({ left: 0, width: 0 });
useEffect(() => {
if (!ref.current) return;
const el = ref.current.querySelector(`[data-key="${value}"]`);
if (el) setThumb({ left: el.offsetLeft, width: el.offsetWidth });
}, [value]);
return (
<div className="seg" ref={ref}>
<div className="seg-thumb" style={{ left: thumb.left, width: thumb.width, background: accent }}/>
{FILTER_TABS.map(t => (
<button key={t.key} data-key={t.key} className={`seg-btn ${value === t.key ? 'is-active' : ''}`}
onClick={() => onChange(t.key)}>
<span>{t.label}</span>
<span className="seg-count">{counts[t.key]}</span>
</button>
))}
</div>
);
}
function UnderlineFilters({ value, onChange, counts, accent }) {
return (
<div className="utabs">
{FILTER_TABS.map(t => (
<button key={t.key} className={`utab ${value === t.key ? 'is-active' : ''}`}
onClick={() => onChange(t.key)}
style={value === t.key ? { '--accent': accent } : undefined}>
<span className="utab-label">{t.label}</span>
<span className="utab-count">{counts[t.key]}</span>
<span className="utab-underline" style={{ background: accent }}/>
</button>
))}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Search input
// ────────────────────────────────────────────────────────────────────
function SearchField({ value, onChange, accent, wide = false }) {
return (
<div className={`search ${wide ? 'search-wide' : ''}`} style={{ '--accent': accent }}>
<Icon.search/>
<input type="text" placeholder="Search games" value={value}
onChange={(e) => onChange(e.target.value)}/>
<span className="search-kbd">/</span>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Sort menu (simple dropdown)
// ────────────────────────────────────────────────────────────────────
const SORTS = [
{ key: 'az', label: 'Name (AZ)' },
{ key: 'size', label: 'Size (largest)' },
{ key: 'recent', label: 'Recently Played' },
{ key: 'state', label: 'Status' },
];
function SortMenu({ value, onChange, accent }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
const current = SORTS.find(s => s.key === value) || SORTS[0];
return (
<div className="sort" ref={ref}>
<button className="sort-btn" onClick={() => setOpen(o => !o)}>
<Icon.sort/>
<span>Sort: <strong>{current.label}</strong></span>
<Icon.chevron/>
</button>
{open && (
<div className="sort-menu">
{SORTS.map(s => (
<button key={s.key} onClick={() => { onChange(s.key); setOpen(false); }}
className={s.key === value ? 'is-active' : ''}
style={s.key === value ? { color: accent } : undefined}>
<span className="sort-check">{s.key === value ? <Icon.check/> : null}</span>
{s.label}
</button>
))}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Storage meter
// ────────────────────────────────────────────────────────────────────
function StorageMeter({ accent, compact = false }) {
const { installed, local, total } = STORAGE;
const pctI = (installed / total) * 100;
const pctL = (local / total) * 100;
return (
<div className={`storage ${compact ? 'storage-compact' : ''}`}>
<div className="storage-bar">
<div className="storage-i" style={{ width: `${pctI}%`, background: accent }}/>
<div className="storage-l" style={{ width: `${pctL}%`, background: `${accent}55` }}/>
</div>
<div className="storage-text">
<span><span className="storage-sq" style={{ background: accent }}/>{installed.toFixed(0)} GB installed</span>
<span><span className="storage-sq" style={{ background: `${accent}55` }}/>{local.toFixed(0)} GB local</span>
<span className="storage-free">{STORAGE.free.toFixed(0)} GB free</span>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Directory button (shows path)
// ────────────────────────────────────────────────────────────────────
function DirectoryButton({ path }) {
const isSet = !!(path && path.trim());
const label = isSet ? 'Game folder' : 'Set game folder';
const tooltip = isSet ? path : 'Please select a game folder';
return (
<button
className={`dirbtn ${isSet ? 'dirbtn-set' : 'dirbtn-unset'}`}
title={tooltip}
aria-label={isSet ? `Game folder: ${path}` : 'Set game folder'}
>
<Icon.folder/>
<span className="dirbtn-label">{label}</span>
<span className="dirbtn-status-dot" aria-hidden="true"/>
</button>
);
}
// ────────────────────────────────────────────────────────────────────
// Menu (kebab)
// ────────────────────────────────────────────────────────────────────
function KebabMenu({ items }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('click', close);
return () => document.removeEventListener('click', close);
}, [open]);
return (
<div className="kebab" ref={ref}>
<button className="kebab-btn" onClick={() => setOpen(o => !o)} aria-label="More"><Icon.kebab/></button>
{open && (
<div className="kebab-menu">
{items.map((it, i) => it === '-' ? <div key={i} className="kebab-sep"/> : (
<button key={i} onClick={() => { setOpen(false); it.onClick && it.onClick(); }}>{it.label}</button>
))}
</div>
)}
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Detail Modal
// ────────────────────────────────────────────────────────────────────
function GameDetailModal({ game, accent, onClose }) {
if (!game) return null;
const action = ACTION_FOR_STATE[game.state];
const showCancel = game.state === 'downloading';
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
<div className="modal-hero">
<GameCover game={game} aspect="banner"/>
<div className="modal-hero-fade"/>
<div className="modal-hero-text">
<div className="modal-tags">
{game.tags.map(t => <span key={t} className="modal-tag">{t}</span>)}
</div>
<h2 className="modal-title">{game.title}</h2>
</div>
<div className="modal-state">
<StateChip state={game.state}/>
</div>
</div>
<div className="modal-body">
<div className="modal-meta">
<div className="meta-cell">
<div className="meta-label">Size</div>
<div className="meta-value">{fmtSize(game.size)}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Players</div>
<div className="meta-value"><Icon.users/> {game.players}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Version</div>
<div className="meta-value meta-mono">{game.version}</div>
</div>
<div className="meta-cell">
<div className="meta-label">Status</div>
<div className="meta-value">{STATE_META[game.state].label || 'Not downloaded'}</div>
</div>
</div>
<p className="modal-desc">{game.desc}</p>
<div className="modal-actions">
<ActionButton state={game.state} accent={accent} size="lg" game={game}/>
{game.canHostServer && game.state === 'installed' && (
<button className="act-btn act-lg act-server"
style={{ '--accent': accent }}
onClick={(e) => e.stopPropagation()}>
<Icon.server/><span>Start Server</span>
</button>
)}
{game.state === 'installed' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Uninstall</span></button>
)}
{game.state === 'local' && (
<button className="ghost-btn ghost-danger"><Icon.trash/><span>Delete from disk</span></button>
)}
{showCancel && (
<button className="ghost-btn ghost-danger"><Icon.close/><span>Cancel</span></button>
)}
{game.state !== 'none' && <div className="modal-actions-spacer"/>}
<button className="ghost-btn">View files</button>
</div>
</div>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────────
// Settings Dialog — in-app version of the Tweaks panel
// ────────────────────────────────────────────────────────────────────
const SETTING_OPTIONS = {
accent: [
{ value: '#3b82f6', label: 'Blue' },
{ value: '#22d3ee', label: 'Cyan' },
{ value: '#a855f7', label: 'Violet' },
{ value: '#22c55e', label: 'Green' },
{ value: '#f59e0b', label: 'Amber' },
{ value: '#ef4444', label: 'Red' },
],
bg: [{ value: 'flat', label: 'Flat' }, { value: 'gradient', label: 'Gradient' }, { value: 'animated', label: 'Animated' }],
density: [{ value: 'compact', label: 'Compact' }, { value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }],
aspect: [{ value: 'box', label: 'Box-art' }, { value: 'square', label: 'Square' }, { value: 'banner', label: 'Banner' }],
language: [{ value: 'en', label: 'English' }, { value: 'de', label: 'Deutsch' }],
};
function SettingsTextInput({ value, placeholder, maxLength = 24, onChange, accent }) {
return (
<div className="settings-text" style={{ '--accent': accent }}>
<input type="text"
value={value || ''}
placeholder={placeholder}
maxLength={maxLength}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}/>
</div>
);
}
function SettingsRow({ label, hint, children }) {
return (
<div className="settings-row">
<div className="settings-row-info">
<div className="settings-row-label">{label}</div>
{hint && <div className="settings-row-hint">{hint}</div>}
</div>
<div className="settings-row-control">{children}</div>
</div>
);
}
function SegmentedRadio({ value, options, onChange, accent }) {
return (
<div className="srad">
{options.map(o => (
<button key={o.value}
className={`srad-btn ${value === o.value ? 'is-active' : ''}`}
onClick={() => onChange(o.value)}
style={value === o.value ? { background: accent, borderColor: accent } : undefined}>
{o.label}
</button>
))}
</div>
);
}
function ColorSwatchPicker({ value, options, onChange }) {
return (
<div className="swatch-row">
{options.map(o => (
<button key={o.value}
className={`swatch ${value === o.value ? 'is-active' : ''}`}
onClick={() => onChange(o.value)}
style={{ color: o.value }}
title={o.label}
aria-label={o.label}>
<span className="swatch-dot" style={{ background: o.value }}/>
{value === o.value && <span className="swatch-check"><Icon.check/></span>}
</button>
))}
</div>
);
}
function SettingsDialog({ settings, onChange, onClose }) {
return (
<div className="modal-scrim" onClick={onClose}>
<div className="modal settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-head">
<h2>Settings</h2>
<button className="modal-close settings-close" onClick={onClose} aria-label="Close"><Icon.close/></button>
</div>
<div className="settings-body">
<div className="settings-section">
<div className="settings-section-title">Profile</div>
<SettingsRow label="Username" hint="Shown to other players on the LAN">
<SettingsTextInput value={settings.username}
placeholder="Enter a username"
onChange={(v) => onChange('username', v)}
accent={settings.accent}/>
</SettingsRow>
<SettingsRow label="Language" hint="Interface language">
<SegmentedRadio value={settings.language || 'en'}
options={SETTING_OPTIONS.language}
onChange={(v) => onChange('language', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
<div className="settings-section">
<div className="settings-section-title">Appearance</div>
<SettingsRow label="Accent color" hint="Used for primary actions and highlights">
<ColorSwatchPicker value={settings.accent}
options={SETTING_OPTIONS.accent}
onChange={(v) => onChange('accent', v)}/>
</SettingsRow>
<SettingsRow label="Background" hint="Backdrop behind the library">
<SegmentedRadio value={settings.bg}
options={SETTING_OPTIONS.bg}
onChange={(v) => onChange('bg', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
<div className="settings-section">
<div className="settings-section-title">Library</div>
<SettingsRow label="Grid density" hint="How tightly cards are packed">
<SegmentedRadio value={settings.density}
options={SETTING_OPTIONS.density}
onChange={(v) => onChange('density', v)}
accent={settings.accent}/>
</SettingsRow>
<SettingsRow label="Cover aspect" hint="Shape of the cover art on each card">
<SegmentedRadio value={settings.aspect}
options={SETTING_OPTIONS.aspect}
onChange={(v) => onChange('aspect', v)}
accent={settings.accent}/>
</SettingsRow>
</div>
</div>
<div className="settings-foot">
<button className="ghost-btn settings-done" onClick={onClose}
style={{ background: settings.accent, borderColor: settings.accent, color: 'white' }}>
Done
</button>
</div>
</div>
</div>
);
}
Object.assign(window, {
Icon, GameCover, StateChip, ActionButton, DownloadProgress, GameCard,
SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
SettingsDialog,
});