Files
Wand-Enhancer-k1tbyte/web-panel/src/library/ui/LibraryDrawer.tsx
T
kitbyte a0b3968d33 refactor(web-panel): update architecture + cheat i18n
Reorganize the panel around product capabilities and integrate the
remote i18n feature from origin/master into the new structure.

- Restructure src into capability features (app, trainer, library,
  remote-session, appearance, shared); move the bridge to bridge/src.
- Add lingui-based UI localization and wrap remaining user-facing
  strings (Trans / msg macros); fix the 'END В·' mojibake.
- Port WeMod cheat-metadata i18n: capture the access token from the
  snapshot's renderer in the bridge and fetch localized trainer_meta
  in remote-session.i18n; guarantee snapshot sync on token failure.
- Wire vitest to the app's lingui/preact pipeline (mergeConfig).
2026-06-15 00:10:49 +03:00

181 lines
7.8 KiB
TypeScript

import { memo, useMemo, type ReactNode } from 'react';
import { msg } from '@lingui/core/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { Icon, type IconName } from '@/shared/ui/Icon';
import { cn } from '@/shared/lib/ui';
import { SearchInput } from '@/shared/ui/SearchInput';
import { filterLibraryGames, formatHours, getLibrarySections, shortPath, type LibraryGame } from '../model/games';
import { GameCover } from './GameCover';
type LibraryDrawerProps = {
games: LibraryGame[];
query: string;
canLaunch: boolean;
onClose: () => void;
onPin: (game: LibraryGame) => void;
onPlay: (game: LibraryGame) => void;
onStop: () => void;
onQueryChange: (query: string) => void;
};
const LibraryDrawerBase = ({ games, query, canLaunch, onClose, onPin, onPlay, onStop, onQueryChange }: LibraryDrawerProps) => {
const { _ } = useLingui();
const filteredGames = useMemo(() => filterLibraryGames(games, query), [games, query]);
const sections = useMemo(() => getLibrarySections(filteredGames), [filteredGames]);
return (
<div className="flex h-full flex-col">
<header className="remote-glass-header flex items-center gap-2.5 border-b px-3.5 py-3.5">
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold text-(--deck-fg)">
<Trans>Library</Trans>
</h2>
<p className="mt-0.5 font-mono text-[11px] text-(--deck-fg-4)">
<Plural value={games.length} one="# game detected" other="# games detected" />
</p>
</div>
<button type="button" aria-label={_(msg`Close library`)} className="remote-glass-control flex size-8 items-center justify-center rounded-[8px] border text-(--deck-fg-2) hover:text-(--deck-fg)" onClick={onClose}>
<Icon className="size-4" name="x" />
</button>
</header>
<div className="border-b border-white/6 px-3.5 py-2.5">
<SearchInput value={query} placeholder={_(msg`Search games`)} onChange={onQueryChange} />
</div>
<div className="remote-scrollbar-hidden min-h-0 flex-1 overflow-y-auto overscroll-contain pb-6">
{sections.running ? (
<GameSection accent count={1} icon="dot" title={_(msg`Now Playing`)}>
<GameRow game={sections.running} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />
</GameSection>
) : null}
{sections.pinned.length > 0 ? (
<GameSection count={sections.pinned.length} icon="star-filled" title={_(msg`Favorites`)}>
{sections.pinned.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
</GameSection>
) : null}
{sections.rest.length > 0 ? (
<GameSection count={sections.rest.length} title={_(msg`All Games`)}>
{sections.rest.map((game) => <GameRow key={game.id} game={game} canLaunch={canLaunch} query={query} onPin={onPin} onPlay={onPlay} onStop={onStop} />)}
</GameSection>
) : null}
{filteredGames.length === 0 ? (
<p className="px-8 py-10 text-center text-[13px] text-(--deck-fg-4)">
<Trans>No games match "{query}"</Trans>
</p>
) : null}
</div>
</div>
);
};
export const LibraryDrawer = memo(LibraryDrawerBase);
type GameSectionProps = {
title: string;
count?: number;
icon?: IconName;
accent?: boolean;
children: ReactNode;
};
const GameSection = ({ title, count, icon, accent = false, children }: GameSectionProps) => {
return (
<section className="mt-2">
<div className="flex items-center gap-2 px-3.5 pb-1.5 pt-3.5">
{icon ? <Icon className={cn('size-3', accent ? 'text-(--deck-accent)' : 'text-(--deck-fg-4)')} name={icon} stroke={2} /> : null}
<h3 className={cn('font-mono text-[10px] font-bold uppercase tracking-[0.18em]', accent ? 'text-(--deck-accent)' : 'text-(--deck-fg-4)')}>{title}</h3>
<div className="h-px flex-1 bg-white/6" />
{typeof count === 'number' ? <span className="font-mono text-[10px] text-(--deck-fg-4)">{count}</span> : null}
</div>
{children}
</section>
);
};
type GameRowProps = {
game: LibraryGame;
canLaunch: boolean;
query: string;
onPin: (game: LibraryGame) => void;
onPlay: (game: LibraryGame) => void;
onStop: () => void;
};
const GameRow = ({ game, canLaunch, query, onPin, onPlay, onStop }: GameRowProps) => {
const { _ } = useLingui();
const hours = formatHours(game.hours);
const handlePin = () => onPin(game);
const handlePlay = () => onPlay(game);
return (
<article className={cn('mx-2 mb-1 flex items-center gap-2.5 rounded-[10px] border px-2.5 py-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]', game.running ? 'border-[color-mix(in_oklab,var(--deck-accent)_24%,transparent)] bg-[color-mix(in_oklab,var(--deck-accent)_7%,transparent)]' : 'border-white/[0.07] bg-white/2.5')}>
<GameCover game={game} />
<div className="min-w-0 flex-1">
<h4 className="truncate text-[13.5px] font-semibold text-(--deck-fg)">{highlightTitle(game.title, query)}</h4>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 font-mono text-[10.5px] tracking-[0.02em] text-(--deck-fg-3)">
<span className="shrink-0">{game.platform.toUpperCase()}</span>
{hours ? <span className="shrink-0">· {hours}</span> : null}
{game.path ? <span className="min-w-0 truncate opacity-60">· {shortPath(game.path)}</span> : null}
</div>
</div>
<div className="flex shrink-0 gap-1">
<IconButton active={game.pinned} label={game.pinned ? _(msg`Remove favorite`) : _(msg`Favorite game`)} icon={game.pinned ? 'star-filled' : 'star'} onClick={handlePin} />
{game.running ? (
<IconButton danger label={_(msg`Stop playing`)} icon="stop" onClick={onStop} />
) : (
<IconButton disabled={!canLaunch || !game.gameId} play label={_(msg`Play`)} icon="play" onClick={handlePlay} />
)}
</div>
</article>
);
};
type IconButtonProps = {
icon: IconName;
label: string;
active?: boolean;
danger?: boolean;
disabled?: boolean;
play?: boolean;
onClick: () => void;
};
const IconButton = ({ icon, label, active = false, danger = false, disabled = false, play = false, onClick }: IconButtonProps) => {
return (
<button
type="button"
aria-label={label}
disabled={disabled}
className={cn(
'remote-glass-control flex size-7.5 items-center justify-center rounded-[7px] border text-(--deck-fg-2) disabled:cursor-not-allowed disabled:opacity-40',
active ? 'border-[color-mix(in_oklab,var(--deck-accent)_38%,transparent)] bg-[color-mix(in_oklab,var(--deck-accent)_16%,transparent)] text-(--deck-accent) shadow-[0_0_14px_-6px_var(--deck-accent)]' : '',
play ? 'bg-[color-mix(in_oklab,var(--deck-accent)_15%,transparent)] text-(--deck-accent) ring-1 ring-[color-mix(in_oklab,var(--deck-accent)_35%,transparent)]' : '',
danger ? 'bg-red-500/15 text-red-300 ring-1 ring-red-400/30' : '',
)}
onClick={onClick}
>
<Icon className={cn('size-3.5', active ? 'drop-shadow-[0_0_5px_var(--deck-accent)]' : '')} name={icon} />
</button>
);
};
function highlightTitle(title: string, query: string): ReactNode {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return title;
}
const index = title.toLowerCase().indexOf(normalized);
if (index < 0) {
return title;
}
const before = title.slice(0, index);
const match = title.slice(index, index + query.length);
const after = title.slice(index + query.length);
return <>{before}<span className="rounded-[3px] bg-[color-mix(in_oklab,var(--deck-accent)_25%,transparent)] px-0.5 text-(--deck-fg)">{match}</span>{after}</>;
}