LoVisual/frontend/src/features/clickgui/ClickGuiWindow.tsx
loki5512344 fd2e29cede
feat(frontend): avatar upload QOL — feedback, re-crop, drop, progress, history
Task 12 batch around the existing crop/preview flow:
- Success Notice (auto-dismiss ~2.5s) after an avatar upload.
- "Reposition" re-crops the current avatar without re-picking a file.
- Drag & drop an image straight onto the profile avatar circle.
- Real upload progress via a new XHR-backed api.upload (fetch has none);
  refreshOnce now also stores the renewed token (reload-restore fix).
- Local avatar history (last 5, IndexedDB, this-device-only) strip that
  re-uploads a stored crop. Stored as ArrayBuffer (Blob is not cloneable
  under structuredClone); label makes the per-device scope explicit.
- Added a "success" tone to Notice.

Infra (same session): registered @shared/@features/@pages/@app/@test path
aliases in vite.config + tsconfigs and rewrote deep ../../../ imports;
moved avatar components into components/avatar/ to keep folders at <=4 files.

Tests: avatarHistory cap/order, recrop visibility, drop-opens-cropper,
client upload/progress/401-retry. bun test 103 pass, lint 0 errors, build ok.
2026-09-28 22:56:01 +02:00

146 lines
5.1 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme'
import { DEMO, useDemo } from './demo'
import './parts/clickgui.css'
import { Backdrop } from './parts/Backdrop'
import { DemoCursor, Icon } from './parts/Glyphs'
import { QUICK_THEMES, TABS } from './parts/modules'
import { ModulesScreen } from './screens/ModulesScreen'
import './screens/screens.css'
import { SettingsScreen } from './screens/SettingsScreen'
import { themeToCssVars } from './themeVars'
type Props = {
/** Runs the scripted cursor demo (pauses while hovered or when `paused`). */
demo?: boolean
paused?: boolean
/** Overrides the active theme — for previews. */
theme?: ThemeEntry
/**
* Controlled theme picking: when set, theme cards in Settings call this instead of switching the
* site-wide theme (the /themes editor loads the preset into its draft).
*/
onPickTheme?: (id: string) => void
/** Image URL painted over the procedural backdrop (e.g. a real in-game F1 screenshot). */
backdrop?: string
className?: string
}
const HINTS = {
modules: ['toggle', 'settings', 'moduleList', 'search', 'close', 'hide'],
settings: ['pick', 'edit', 'wheel', 'close', 'hide'],
} as const
function useClock(): string {
const format = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
const [now, setNow] = useState(format)
useEffect(() => {
const id = window.setInterval(() => setNow(format()), 15_000)
return () => window.clearInterval(id)
}, [])
return now
}
/**
* A faithful replica of the in-game ClickGui: the top pill bar (clock + Modules/Settings switch),
* the four glass category panels, and the Settings → Темы page whose cards re-theme everything.
*/
export function ClickGuiWindow({ demo = true, paused = false, theme: override, onPickTheme, backdrop, className = '' }: Props) {
const { t } = useTranslation('clickgui')
const { theme: active, setTheme } = useActiveTheme()
const theme = override ?? active
const rootRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
const [reduced] = useState(prefersReducedMotion)
const clock = useClock()
const themeIdRef = useRef(theme.id)
useEffect(() => {
themeIdRef.current = theme.id
}, [theme.id])
const { cursor, state, dispatch } = useDemo(DEMO, {
paused: !demo || paused || hovered,
reduced,
rootRef,
nextTheme: () => {
const i = QUICK_THEMES.indexOf(themeIdRef.current as (typeof QUICK_THEMES)[number])
return QUICK_THEMES[(i + 1) % QUICK_THEMES.length]
},
onTheme: (id) => (onPickTheme ? onPickTheme(id) : setTheme(id, 'demo')),
})
const vars = useMemo(
() => (backdrop ? { ...themeToCssVars(theme), '--gui-backdrop-image': `url(${JSON.stringify(backdrop)})` } : themeToCssVars(theme)),
[theme, backdrop],
)
const pick = onPickTheme ?? ((id: string) => setTheme(id, 'user'))
return (
<div
ref={rootRef}
role="group"
aria-label={t('windowLabel')}
data-testid="clickgui-root"
className={`lv-gui ${className}`}
style={vars}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
>
<div className="lv-gui-stage" data-tab={state.tab}>
<div className="lv-gui-world" aria-hidden="true">
<Backdrop />
<div className="lv-gui-photo" />
</div>
<p className="lv-gui-watermark" aria-hidden="true">
<b>LoVisual</b>·400 fps <span>0 ms</span>
</p>
<div className="lv-gui-bar">
<time className="lv-gui-clock" aria-label={t('clock')}>
{clock}
</time>
<div className="lv-gui-tabs" role="tablist" aria-label={t('tabsLabel')}>
<span className="lv-gui-tab-thumb" aria-hidden="true" />
{TABS.map((id) => (
<button
key={id}
type="button"
role="tab"
className="lv-gui-tab"
aria-selected={state.tab === id}
data-demo={`tab:${id}`}
onClick={() => dispatch({ type: 'tab', id })}
>
{t(`tabs.${id}`)}
</button>
))}
</div>
</div>
{state.tab === 'modules' && (
<button type="button" className="lv-gui-search" aria-label={t('search')}>
<Icon name="search" className="lv-gui-search-icon" />
</button>
)}
<div className="lv-gui-screen" key={state.tab}>
{state.tab === 'modules' ? (
<ModulesScreen state={state} dispatch={dispatch} />
) : (
<SettingsScreen activeId={theme.id} onPick={pick} />
)}
</div>
<ul className="lv-gui-hints" aria-hidden="true">
{HINTS[state.tab].map((k) => (
<li key={k}>{t(`hints.${state.tab}.${k}` as `hints.modules.toggle`)}</li>
))}
</ul>
</div>
<DemoCursor cursor={cursor} />
</div>
)
}