19ae1938f6
Move the design contract for choosing the library game folder out of the top bar and into Settings > Library. The launcher chrome now reserves the right edge for the kebab/menu controls, while Settings owns the required folder path and its set/unset states. The reference mock now persists `gameFolder: string | null` instead of a boolean, adds an exercisable GameFolderField, and includes matching CSS so the prototype reflects the documented interaction. This is still design/reference only; no runtime Tauri settings code changes here. Test Plan: - git diff --cached --check Refs: none
646 lines
32 KiB
React
646 lines
32 KiB
React
// 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 (A–Z)' },
|
||
{ 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>
|
||
</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 GameFolderField({ value, onChange, accent }) {
|
||
const isSet = !!(value && value.trim());
|
||
const handleChange = () => {
|
||
// In production: open native folder picker via Tauri.
|
||
// For the prototype, prompt for a path so the field is exercisable.
|
||
const next = window.prompt('Game folder path (leave empty to clear)', value || '');
|
||
if (next == null) return;
|
||
onChange(next.trim());
|
||
};
|
||
return (
|
||
<div className={`folder-field ${isSet ? 'is-set' : 'is-unset'}`}
|
||
style={{ '--accent': accent }}>
|
||
<span className="folder-field-icon" aria-hidden="true"><Icon.folder/></span>
|
||
<div className="folder-field-path" title={isSet ? value : 'No folder selected'}>
|
||
{isSet
|
||
? <bdi>{value}</bdi>
|
||
: <span className="folder-field-empty">Not set</span>}
|
||
</div>
|
||
<button type="button" className="folder-field-btn" onClick={handleChange}>
|
||
{isSet ? 'Change\u2026' : 'Choose\u2026'}
|
||
</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="Game folder" hint="Parent directory where games are downloaded and installed">
|
||
<GameFolderField value={settings.gameFolder}
|
||
onChange={(v) => onChange('gameFolder', v)}
|
||
accent={settings.accent}/>
|
||
</SettingsRow>
|
||
<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,
|
||
});
|