// 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 */}
{/* 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 (
{ e.stopPropagation(); onClick && onClick(); }}
style={action.kind === 'install' ? { background: accent } : undefined}>
{icon}{action.label}
);
}
// ────────────────────────────────────────────────────────────────────
// Game card
// ────────────────────────────────────────────────────────────────────
function GameCard({ game, accent, aspect, onOpen }) {
return (
onOpen && onOpen(game)} tabIndex={0}>
{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 => (
onChange(t.key)}>
{t.label}
{counts[t.key]}
))}
);
}
function UnderlineFilters({ value, onChange, counts, accent }) {
return (
{FILTER_TABS.map(t => (
onChange(t.key)}
style={value === t.key ? { '--accent': accent } : undefined}>
{t.label}
{counts[t.key]}
))}
);
}
// ────────────────────────────────────────────────────────────────────
// 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 (
setOpen(o => !o)}>
Sort: {current.label}
{open && (
{SORTS.map(s => (
{ onChange(s.key); setOpen(false); }}
className={s.key === value ? 'is-active' : ''}
style={s.key === value ? { color: accent } : undefined}>
{s.key === value ? : null}
{s.label}
))}
)}
);
}
// ────────────────────────────────────────────────────────────────────
// 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 (
Game directory
{short}
);
}
// ────────────────────────────────────────────────────────────────────
// 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 (
setOpen(o => !o)} aria-label="More">
{open && (
{items.map((it, i) => it === '-' ?
: (
{ setOpen(false); it.onClick && it.onClick(); }}>{it.label}
))}
)}
);
}
// ────────────────────────────────────────────────────────────────────
// 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)}
Status
{STATE_META[game.state].label || 'Not downloaded'}
{game.desc}
{game.state === 'installed' && (
Uninstall
)}
{game.state === 'local' && (
Delete from disk
)}
{game.state !== 'none' &&
}
View files
);
}
// ────────────────────────────────────────────────────────────────────
// 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 (
);
}
function SegmentedRadio({ value, options, onChange, accent }) {
return (
{options.map(o => (
onChange(o.value)}
style={value === o.value ? { background: accent, borderColor: accent } : undefined}>
{o.label}
))}
);
}
function ColorSwatchPicker({ value, options, onChange }) {
return (
{options.map(o => (
onChange(o.value)}
style={{ color: o.value }}
title={o.label}
aria-label={o.label}>
{value === o.value && }
))}
);
}
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}/>
Done
);
}
Object.assign(window, {
Icon, GameCover, StateChip, ActionButton, GameCard,
SegmentedFilters, UnderlineFilters, SearchField, SortMenu,
StorageMeter, DirectoryButton, KebabMenu, GameDetailModal,
SettingsDialog,
});