feat(web): заготовка веб-клиента на Vite + React 19 + TypeScript strict
- стек по §5.2: React 19, React Router, TanStack Query, Zustand, Tailwind CSS 4, i18next (ru/en), Vitest + Testing Library - дизайн-токены §3 docs/client-web.md (тёмная тема по умолчанию, светлая, accent) - типизированный API-клиент §8.1: единый формат ошибок, ApiError с кодом - App: экран состояния фундамента с readz/meta, локализация ошибок по code - тесты: клиент API, форматирование, рендер App (успех/ошибка кода/сеть) - eslint + prettier + tsc strict; initial JS 97.6 KB gzip
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fetchReadiness, readyQueryKey } from '@/api/health';
|
||||
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||
import { StatBytes, StatRow } from '@/components/ui/StatRow';
|
||||
import { errorCode } from '@/lib/format';
|
||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
|
||||
export function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const theme = useUiStore((state) => state.theme);
|
||||
const toggleTheme = useUiStore((state) => state.toggleTheme);
|
||||
const locale = i18n.resolvedLanguage ?? 'ru';
|
||||
|
||||
const meta = useQuery({
|
||||
queryKey: metaQueryKey,
|
||||
queryFn: ({ signal }) => fetchMeta(signal),
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const readiness = useQuery({
|
||||
queryKey: readyQueryKey,
|
||||
queryFn: ({ signal }) => fetchReadiness(signal),
|
||||
retry: 1,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = readiness.isSuccess || meta.isSuccess;
|
||||
const schemaReady = readiness.data?.checks['database'] === 'ok';
|
||||
|
||||
const errorMessage = (error: unknown): string => {
|
||||
const code = errorCode(error);
|
||||
const key = `errors.${code}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? t('errors.unknown', { code }) : translated;
|
||||
};
|
||||
|
||||
const changeLanguage = (language: SupportedLanguage): void => {
|
||||
window.localStorage.setItem('glchat.language', language);
|
||||
void i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-6 px-4 py-10">
|
||||
<header className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('app.name')}</h1>
|
||||
<p className="mt-1 text-fg-muted">{t('app.tagline')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge aria-live="polite" data-testid="connection-state">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2 w-2 rounded-full ${online ? 'bg-success' : 'bg-danger'}`}
|
||||
/>
|
||||
{t(online ? 'health.online' : 'health.offline')}
|
||||
</Badge>
|
||||
<Button variant="ghost" onClick={toggleTheme}>
|
||||
{t(theme === 'dark' ? 'theme.switchToLight' : 'theme.switchToDark')}
|
||||
</Button>
|
||||
<label className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<span className="sr-only">{t('language.label')}</span>
|
||||
<select
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
|
||||
value={locale.startsWith('en') ? 'en' : 'ru'}
|
||||
onChange={(event) => changeLanguage(event.target.value === 'en' ? 'en' : 'ru')}
|
||||
>
|
||||
{supportedLanguages.map((language) => (
|
||||
<option key={language} value={language}>
|
||||
{t(`language.${language}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold">{t('status.title')}</h2>
|
||||
<p className="mt-1 text-fg-muted">{t('status.description')}</p>
|
||||
|
||||
{meta.isPending ? (
|
||||
<p className="mt-4 text-fg-muted" role="status">
|
||||
{t('status.connecting')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{meta.isError ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-4" role="alert">
|
||||
<p className="text-danger">{errorMessage(meta.error)}</p>
|
||||
<Button variant="ghost" onClick={() => void meta.refetch()}>
|
||||
{t('status.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{meta.data ? (
|
||||
<dl className="mt-4 text-sm">
|
||||
<StatRow label={t('status.instance')} value={meta.data.name} />
|
||||
<StatRow label={t('status.version')} value={meta.data.version} />
|
||||
<StatRow label={t('status.apiVersion')} value={meta.data.api_version} />
|
||||
<StatRow
|
||||
label={t('status.schemaReady')}
|
||||
value={schemaReady ? t('status.schemaOk') : (readiness.data?.status ?? '…')}
|
||||
/>
|
||||
<StatRow
|
||||
label={t('status.maxUpload')}
|
||||
value={<StatBytes bytes={meta.data.max_upload_size} locale={locale} />}
|
||||
/>
|
||||
</dl>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<footer className="mt-auto flex items-center justify-between text-xs text-fg-muted">
|
||||
<span>
|
||||
{t('app.name')} · {t('footer.license')}
|
||||
</span>
|
||||
<span className="tabular-nums">{meta.data?.commit ?? ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user