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:
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user