feat(ui): move game folder picker into settings

The design update moved game-folder configuration out of launcher chrome and
into Settings > Library. Follow that contract in the runtime UI without
changing the existing storage or Tauri directory commands.

The top bar now leaves its right edge for the kebab menu. Settings owns a new
Game folder row that shows a valid selected path with a neutral Change button,
or the red Not set state with a stronger Choose button when no accessible
directory is configured. Both the empty-library state and the Settings row
still use the existing native directory picker, so existing saved paths and
rescans keep their current behavior.

Keep useGameDirectory as the directory-state owner and expose the shared
hasGameDirectory boolean from that hook so the grid and Settings field agree on
what counts as configured.

Test Plan:
- git diff --cached --check
- just frontend-test
- just build

Refs: 62b409f4bfc4995c25461776107d28f52b24f30e
This commit is contained in:
2026-05-21 20:42:12 +02:00
parent eedfc0105d
commit 2e7a0cff2f
6 changed files with 128 additions and 82 deletions
@@ -14,6 +14,9 @@ import {
interface Props { interface Props {
settings: UISettings; settings: UISettings;
gameDir: string;
hasGameDirectory: boolean;
onPickDirectory: () => void;
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void; onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
onClose: () => void; onClose: () => void;
} }
@@ -56,7 +59,37 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
</div> </div>
); );
export const SettingsDialog = ({ settings, onChange, onClose }: Props) => ( interface GameFolderFieldProps {
path: string;
isValid: boolean;
onPickDirectory: () => void;
}
const GameFolderField = ({ path, isValid, onPickDirectory }: GameFolderFieldProps) => (
<div className={`folder-field ${isValid ? 'is-set' : 'is-unset'}`}>
<Icon.folder className="folder-field-icon" aria-hidden="true" />
<div className="folder-field-path" title={isValid ? path : 'No folder selected'}>
{isValid ? <bdi>{path}</bdi> : <span className="folder-field-empty">Not set</span>}
</div>
<button
type="button"
className="folder-field-btn"
aria-label={isValid ? 'Change game folder' : 'Choose game folder'}
onClick={onPickDirectory}
>
{isValid ? 'Change…' : 'Choose…'}
</button>
</div>
);
export const SettingsDialog = ({
settings,
gameDir,
hasGameDirectory,
onPickDirectory,
onChange,
onClose,
}: Props) => (
<Modal onClose={onClose} className="settings-modal"> <Modal onClose={onClose} className="settings-modal">
<div className="settings-head"> <div className="settings-head">
<h2>Settings</h2> <h2>Settings</h2>
@@ -110,6 +143,13 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
<section className="settings-section"> <section className="settings-section">
<div className="settings-section-title">Library</div> <div className="settings-section-title">Library</div>
<Row label="Game folder" hint="Parent directory where games are downloaded and installed">
<GameFolderField
path={gameDir}
isValid={hasGameDirectory}
onPickDirectory={onPickDirectory}
/>
</Row>
<Row label="Grid density" hint="How tightly cards are packed"> <Row label="Grid density" hint="How tightly cards are packed">
<SegmentedRadio <SegmentedRadio
value={settings.density} value={settings.density}
@@ -1,28 +0,0 @@
import { Icon } from '../Icon';
interface Props {
path: string | null;
exists: boolean;
onClick: () => void;
}
export const DirectoryButton = ({ path, exists, onClick }: Props) => {
const isSet = !!(path && path.trim());
const isValid = isSet && exists;
const label = isValid ? 'Game folder' : 'Set game folder';
const tooltip = isValid ? (path as string) : 'Please select a game folder';
const ariaLabel = isValid ? `Game folder: ${path}` : 'Set game folder';
return (
<button
type="button"
className={`dirbtn ${isValid ? 'dirbtn-set' : 'dirbtn-unset'}`}
title={tooltip}
aria-label={ariaLabel}
onClick={onClick}
>
<Icon.folder />
<span className="dirbtn-label">{label}</span>
</button>
);
};
@@ -2,7 +2,6 @@ import { Brand } from '../Brand';
import { SegmentedFilters } from './SegmentedFilters'; import { SegmentedFilters } from './SegmentedFilters';
import { SearchField } from './SearchField'; import { SearchField } from './SearchField';
import { SortMenu } from './SortMenu'; import { SortMenu } from './SortMenu';
import { DirectoryButton } from './DirectoryButton';
import { KebabMenu, KebabItem } from './KebabMenu'; import { KebabMenu, KebabItem } from './KebabMenu';
import { FilterCounts } from '../../lib/gameState'; import { FilterCounts } from '../../lib/gameState';
@@ -17,9 +16,6 @@ interface Props {
setQuery: (value: string) => void; setQuery: (value: string) => void;
sort: GameSort; sort: GameSort;
setSort: (value: GameSort) => void; setSort: (value: GameSort) => void;
gameDir: string;
gameDirExists: boolean;
onPickDirectory: () => void;
kebabItems: ReadonlyArray<KebabItem>; kebabItems: ReadonlyArray<KebabItem>;
} }
@@ -32,9 +28,6 @@ export const TopBar = ({
setQuery, setQuery,
sort, sort,
setSort, setSort,
gameDir,
gameDirExists,
onPickDirectory,
kebabItems, kebabItems,
}: Props) => ( }: Props) => (
<header className="topbar"> <header className="topbar">
@@ -52,7 +45,6 @@ export const TopBar = ({
<SortMenu value={sort} onChange={setSort} /> <SortMenu value={sort} onChange={setSort} />
</div> </div>
<div className="topbar-right-tail"> <div className="topbar-right-tail">
<DirectoryButton path={gameDir} exists={gameDirExists} onClick={onPickDirectory} />
<KebabMenu items={kebabItems} /> <KebabMenu items={kebabItems} />
</div> </div>
</div> </div>
@@ -64,6 +64,8 @@ export const useGameDirectory = () => {
}; };
}, [gameDir]); }, [gameDir]);
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
const rescan = useCallback(() => { const rescan = useCallback(() => {
if (!gameDir.trim()) { if (!gameDir.trim()) {
setGameDirExists(false); setGameDirExists(false);
@@ -86,5 +88,5 @@ export const useGameDirectory = () => {
void sync(); void sync();
}, [gameDir]); }, [gameDir]);
return { gameDir, gameDirExists, setGameDir, rescan }; return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
}; };
@@ -394,44 +394,6 @@
color: var(--accent); color: var(--accent);
} }
/* Game-folder button: icon + short label.
Full path lives in tooltip + aria-label, not on-screen. */
.dirbtn {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px 0 12px;
background: var(--bg-2);
border: 1px solid var(--bd-1);
border-radius: 8px;
color: var(--t-1);
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition:
border-color 0.15s,
color 0.15s,
background 0.15s;
}
.dirbtn:hover {
border-color: var(--bd-2);
}
.dirbtn-label {
line-height: 1;
}
.dirbtn-unset {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
}
.dirbtn-unset:hover {
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
background: color-mix(in srgb, var(--danger) 8%, var(--bg-2));
}
/* Kebab menu */ /* Kebab menu */
.kebab { .kebab {
position: relative; position: relative;
@@ -1600,6 +1562,86 @@
font-weight: 500; font-weight: 500;
} }
/* Settings: game-folder field */
.folder-field {
display: inline-flex;
align-items: center;
gap: 8px;
width: 340px;
height: 36px;
padding: 0 4px 0 12px;
background: var(--bg-3);
border: 1px solid var(--bd-1);
border-radius: 8px;
transition:
border-color 0.15s,
background 0.15s;
}
.folder-field:hover {
border-color: var(--bd-2);
}
.folder-field.is-unset {
border-color: color-mix(in srgb, var(--danger) 35%, var(--bd-1));
background: color-mix(in srgb, var(--danger) 6%, var(--bg-3));
}
.folder-field.is-unset:hover {
border-color: color-mix(in srgb, var(--danger) 55%, var(--bd-2));
}
.folder-field-icon {
color: var(--t-3);
flex-shrink: 0;
}
.folder-field.is-unset .folder-field-icon {
color: #f87171;
}
.folder-field-path {
flex: 1;
min-width: 0;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 12px;
color: var(--t-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
}
.folder-field-empty {
font-family: var(--font-ui);
font-size: 12.5px;
font-weight: 600;
color: #f87171;
letter-spacing: 0;
}
.folder-field-btn {
flex-shrink: 0;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
color: var(--t-1);
font: inherit;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.folder-field-btn:hover {
background: rgba(255, 255, 255, 0.12);
}
.folder-field.is-unset .folder-field-btn {
background: color-mix(in srgb, var(--accent) 85%, transparent);
color: #fff;
}
.folder-field.is-unset .folder-field-btn:hover {
background: var(--accent);
}
/* Settings: color swatches */ /* Settings: color swatches */
.swatch-row { .swatch-row {
display: inline-flex; display: inline-flex;
@@ -45,7 +45,7 @@ const openLogsWindow = async () => {
export const MainWindow = () => { export const MainWindow = () => {
const { settings, set: setSetting } = useSettings(); const { settings, set: setSetting } = useSettings();
const { gameDir, gameDirExists, setGameDir, rescan } = useGameDirectory(); const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
const games = useGames(rescan); const games = useGames(rescan);
const actions = useGameActions(games, settings); const actions = useGameActions(games, settings);
const thumbnails = useThumbnails(); const thumbnails = useThumbnails();
@@ -53,8 +53,6 @@ export const MainWindow = () => {
const [openGameId, setOpenGameId] = useState<string | null>(null); const [openGameId, setOpenGameId] = useState<string | null>(null);
const [removeGameId, setRemoveGameId] = useState<string | null>(null); const [removeGameId, setRemoveGameId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const hasGameDirectory = !!gameDir.trim() && gameDirExists;
const visibleGames = useMemo( const visibleGames = useMemo(
() => hasGameDirectory ? games.games : [], () => hasGameDirectory ? games.games : [],
[games.games, hasGameDirectory], [games.games, hasGameDirectory],
@@ -130,9 +128,6 @@ export const MainWindow = () => {
setQuery={setQuery} setQuery={setQuery}
sort={settings.sort} sort={settings.sort}
setSort={(v) => setSetting('sort', v)} setSort={(v) => setSetting('sort', v)}
gameDir={gameDir}
gameDirExists={gameDirExists}
onPickDirectory={() => void pickDirectory()}
kebabItems={kebabItems} kebabItems={kebabItems}
/> />
<main className="grid-wrap"> <main className="grid-wrap">
@@ -192,6 +187,9 @@ export const MainWindow = () => {
{settingsOpen && ( {settingsOpen && (
<SettingsDialog <SettingsDialog
settings={settings} settings={settings}
gameDir={gameDir}
hasGameDirectory={hasGameDirectory}
onPickDirectory={() => void pickDirectory()}
onChange={setSetting} onChange={setSetting}
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
/> />