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 {
|
||||
settings: UISettings;
|
||||
gameDir: string;
|
||||
hasGameDirectory: boolean;
|
||||
onPickDirectory: () => void;
|
||||
onChange: <K extends keyof UISettings>(key: K, value: UISettings[K]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -56,7 +59,37 @@ const SettingsTextInput = ({ value, placeholder, maxLength, onChange }: TextInpu
|
||||
</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">
|
||||
<div className="settings-head">
|
||||
<h2>Settings</h2>
|
||||
@@ -110,6 +143,13 @@ export const SettingsDialog = ({ settings, onChange, onClose }: Props) => (
|
||||
|
||||
<section className="settings-section">
|
||||
<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">
|
||||
<SegmentedRadio
|
||||
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 { SearchField } from './SearchField';
|
||||
import { SortMenu } from './SortMenu';
|
||||
import { DirectoryButton } from './DirectoryButton';
|
||||
import { KebabMenu, KebabItem } from './KebabMenu';
|
||||
|
||||
import { FilterCounts } from '../../lib/gameState';
|
||||
@@ -17,9 +16,6 @@ interface Props {
|
||||
setQuery: (value: string) => void;
|
||||
sort: GameSort;
|
||||
setSort: (value: GameSort) => void;
|
||||
gameDir: string;
|
||||
gameDirExists: boolean;
|
||||
onPickDirectory: () => void;
|
||||
kebabItems: ReadonlyArray<KebabItem>;
|
||||
}
|
||||
|
||||
@@ -32,9 +28,6 @@ export const TopBar = ({
|
||||
setQuery,
|
||||
sort,
|
||||
setSort,
|
||||
gameDir,
|
||||
gameDirExists,
|
||||
onPickDirectory,
|
||||
kebabItems,
|
||||
}: Props) => (
|
||||
<header className="topbar">
|
||||
@@ -52,7 +45,6 @@ export const TopBar = ({
|
||||
<SortMenu value={sort} onChange={setSort} />
|
||||
</div>
|
||||
<div className="topbar-right-tail">
|
||||
<DirectoryButton path={gameDir} exists={gameDirExists} onClick={onPickDirectory} />
|
||||
<KebabMenu items={kebabItems} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,8 @@ export const useGameDirectory = () => {
|
||||
};
|
||||
}, [gameDir]);
|
||||
|
||||
const hasGameDirectory = gameDir.trim() !== '' && gameDirExists;
|
||||
|
||||
const rescan = useCallback(() => {
|
||||
if (!gameDir.trim()) {
|
||||
setGameDirExists(false);
|
||||
@@ -86,5 +88,5 @@ export const useGameDirectory = () => {
|
||||
void sync();
|
||||
}, [gameDir]);
|
||||
|
||||
return { gameDir, gameDirExists, setGameDir, rescan };
|
||||
return { gameDir, gameDirExists, hasGameDirectory, setGameDir, rescan };
|
||||
};
|
||||
|
||||
@@ -394,44 +394,6 @@
|
||||
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 {
|
||||
position: relative;
|
||||
@@ -1600,6 +1562,86 @@
|
||||
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 */
|
||||
.swatch-row {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -45,7 +45,7 @@ const openLogsWindow = async () => {
|
||||
|
||||
export const MainWindow = () => {
|
||||
const { settings, set: setSetting } = useSettings();
|
||||
const { gameDir, gameDirExists, setGameDir, rescan } = useGameDirectory();
|
||||
const { gameDir, hasGameDirectory, setGameDir, rescan } = useGameDirectory();
|
||||
const games = useGames(rescan);
|
||||
const actions = useGameActions(games, settings);
|
||||
const thumbnails = useThumbnails();
|
||||
@@ -53,8 +53,6 @@ export const MainWindow = () => {
|
||||
const [openGameId, setOpenGameId] = useState<string | null>(null);
|
||||
const [removeGameId, setRemoveGameId] = useState<string | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const hasGameDirectory = !!gameDir.trim() && gameDirExists;
|
||||
const visibleGames = useMemo(
|
||||
() => hasGameDirectory ? games.games : [],
|
||||
[games.games, hasGameDirectory],
|
||||
@@ -130,9 +128,6 @@ export const MainWindow = () => {
|
||||
setQuery={setQuery}
|
||||
sort={settings.sort}
|
||||
setSort={(v) => setSetting('sort', v)}
|
||||
gameDir={gameDir}
|
||||
gameDirExists={gameDirExists}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
kebabItems={kebabItems}
|
||||
/>
|
||||
<main className="grid-wrap">
|
||||
@@ -192,6 +187,9 @@ export const MainWindow = () => {
|
||||
{settingsOpen && (
|
||||
<SettingsDialog
|
||||
settings={settings}
|
||||
gameDir={gameDir}
|
||||
hasGameDirectory={hasGameDirectory}
|
||||
onPickDirectory={() => void pickDirectory()}
|
||||
onChange={setSetting}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user