// 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) => , play: (p) => , install:(p) => , download:(p)=> , folder: (p) => , kebab: (p) => , sort: (p) => , users: (p) => , close: (p) => , check: (p) => , chevron:(p) => , trash: (p) => , }; // ──────────────────────────────────────────────────────────────────── // 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 (
{/* base gradient */}
{/* radial accent blob */}
{/* scanline / grain */}
{/* faint geometric mark */} {/* title */}
{title}
{/* bottom darkening */}
); } // ──────────────────────────────────────────────────────────────────── // State chip (corner of cover) // ──────────────────────────────────────────────────────────────────── function StateChip({ state }) { const meta = STATE_META[state]; if (!meta || !meta.label) return null; return (
{meta.label}
); } // ──────────────────────────────────────────────────────────────────── // Action button — Play / Install / Download // ──────────────────────────────────────────────────────────────────── function ActionButton({ state, accent, size = 'md', onClick, full = false }) { 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' ? : action.kind === 'install' ? : ; return ( ); } // ──────────────────────────────────────────────────────────────────── // Game card // ──────────────────────────────────────────────────────────────────── function GameCard({ game, accent, aspect, onOpen }) { return (
onOpen && onOpen(game)} tabIndex={0}>
{game.players}
{game.title}
{fmtSize(game.size)} · {game.tags[0]}
); } // ──────────────────────────────────────────────────────────────────── // 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 (
{FILTER_TABS.map(t => ( ))}
); } function UnderlineFilters({ value, onChange, counts, accent }) { return (
{FILTER_TABS.map(t => ( ))}
); } // ──────────────────────────────────────────────────────────────────── // Search input // ──────────────────────────────────────────────────────────────────── function SearchField({ value, onChange, accent, wide = false }) { return (
onChange(e.target.value)}/> /
); } // ──────────────────────────────────────────────────────────────────── // 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 (
{open && (
{SORTS.map(s => ( ))}
)}
); } // ──────────────────────────────────────────────────────────────────── // Storage meter // ──────────────────────────────────────────────────────────────────── function StorageMeter({ accent, compact = false }) { const { installed, local, total } = STORAGE; const pctI = (installed / total) * 100; const pctL = (local / total) * 100; return (
{installed.toFixed(0)} GB installed {local.toFixed(0)} GB local {STORAGE.free.toFixed(0)} GB free
); } // ──────────────────────────────────────────────────────────────────── // Directory button (shows path) // ──────────────────────────────────────────────────────────────────── function DirectoryButton({ path }) { const short = path.length > 36 ? '…' + path.slice(-34) : path; return ( ); } // ──────────────────────────────────────────────────────────────────── // 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 (
{open && (
{items.map((it, i) => it === '-' ?
: ( ))}
)}
); } // ──────────────────────────────────────────────────────────────────── // Detail Modal // ──────────────────────────────────────────────────────────────────── function GameDetailModal({ game, accent, onClose }) { if (!game) return null; const action = ACTION_FOR_STATE[game.state]; return (
e.stopPropagation()}>
{game.tags.map(t => {t})}

{game.title}

Size
{fmtSize(game.size)}
Players
{game.players}
Version
{game.version}
Status
{STATE_META[game.state].label || 'Not downloaded'}

{game.desc}

{game.state === 'installed' && ( )} {game.state === 'local' && ( )} {game.state !== 'none' &&
}
); } // ──────────────────────────────────────────────────────────────────── // 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' }], }; function SettingsRow({ label, hint, children }) { return (
{label}
{hint &&
{hint}
}
{children}
); } function SegmentedRadio({ value, options, onChange, accent }) { return (
{options.map(o => ( ))}
); } function ColorSwatchPicker({ value, options, onChange }) { return (
{options.map(o => ( ))}
); } function SettingsDialog({ settings, onChange, onClose }) { return (
e.stopPropagation()}>

Settings

Appearance
onChange('accent', v)}/> onChange('bg', v)} accent={settings.accent}/>
Library
onChange('density', v)} accent={settings.accent}/> onChange('aspect', v)} accent={settings.accent}/>
); } Object.assign(window, { Icon, GameCover, StateChip, ActionButton, GameCard, SegmentedFilters, UnderlineFilters, SearchField, SortMenu, StorageMeter, DirectoryButton, KebabMenu, GameDetailModal, SettingsDialog, });