Merge branch 'fix/topbar-restore-full-cluster' into 'main'

fix(topbar): restore full right cluster (Docs/VersionBadge/LangSwitch)

See merge request 2-6/2-6-4/terravault/ordinis!84
This commit is contained in:
Александр Зимин
2026-05-11 16:56:06 +00:00
3 changed files with 48 additions and 53 deletions
@@ -1,42 +1,37 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Monitor, Moon, Sun } from 'lucide-react'
import { useTheme, type ThemePreference } from '@/stores/ThemeProvider' import { useTheme, type ThemePreference } from '@/stores/ThemeProvider'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
/** /**
* Theme switch per redesign prototype — 2-button segmented control с text * Tri-state theme switch: Light / Dark / System.
* labels "Earth | Dark". Filled accent-bg на active.
* *
* <p>Прошлый tri-state (Light/Dark/System icons) был noise — "system" preference * <p>Segmented control с тремя иконками. Активный — `--color-accent`
* редко используется явно, мало кто понимает Monitor icon. Простой 2-button * background + `--color-on-accent` foreground. Click меняет `preference`
* `Earth / Dark` toggle — точно повторяет prototype и улучшает discoverability. * в {@link ThemeProvider}, который пишет в localStorage и переключает
* `data-theme` на html.
* *
* <p>Internally "Earth" maps на `light` preference (Earthy palette default). * <p>System icon показывает «follow OS» — auto-update при OS theme change.
* "System" preference больше не выставляется через UI; localStorage value
* остаётся валидным (ThemeProvider может read), но новые users получают
* explicit light/dark choice.
*/ */
export function ThemeSwitch() { export function ThemeSwitch() {
const { t } = useTranslation() const { t } = useTranslation()
const { preference, setPreference } = useTheme() const { preference, setPreference } = useTheme()
// Treat 'system' как 'light' для UI — переключение всё равно flatten'ит const items: { id: ThemePreference; label: string; icon: typeof Sun }[] = [
// preference на explicit choice. { id: 'light', label: t('theme.light'), icon: Sun },
const activeId: Exclude<ThemePreference, 'system'> = { id: 'dark', label: t('theme.dark'), icon: Moon },
preference === 'dark' ? 'dark' : 'light' { id: 'system', label: t('theme.system'), icon: Monitor },
const items: { id: Exclude<ThemePreference, 'system'>; label: string }[] = [
{ id: 'light', label: t('theme.earth', { defaultValue: 'Earth' }) },
{ id: 'dark', label: t('theme.dark', { defaultValue: 'Dark' }) },
] ]
return ( return (
<div <div
role="radiogroup" role="radiogroup"
aria-label={t('theme.label')} aria-label={t('theme.label')}
className="inline-flex items-center rounded-md border border-line bg-surface overflow-hidden" className="inline-flex items-center rounded-md border border-line bg-surface p-0.5"
> >
{items.map((item) => { {items.map((item) => {
const active = activeId === item.id const Icon = item.icon
const active = preference === item.id
return ( return (
<button <button
key={item.id} key={item.id}
@@ -44,15 +39,16 @@ export function ThemeSwitch() {
role="radio" role="radio"
aria-checked={active} aria-checked={active}
aria-label={item.label} aria-label={item.label}
title={item.label}
onClick={() => setPreference(item.id)} onClick={() => setPreference(item.id)}
className={cn( className={cn(
'h-8 px-3 text-cap font-semibold tracking-[0.14em] transition-colors', 'inline-flex h-6 w-6 items-center justify-center rounded transition-colors',
active active
? 'bg-accent text-on-accent' ? 'bg-accent text-on-accent'
: 'text-ink-2 hover:text-ink hover:bg-surface-2', : 'text-mute hover:text-ink hover:bg-surface-2',
)} )}
> >
{item.label} <Icon size={13} strokeWidth={2.25} />
</button> </button>
) )
})} })}
@@ -1,12 +1,19 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from '@tanstack/react-router' import { Link, useLocation, useNavigate } from '@tanstack/react-router'
import { useDictionaryDetail } from '@/api/queries' import { useDictionaryDetail } from '@/api/queries'
import { LanguageSwitch } from '@/ui'
import { AuthBadge } from '@/auth/AuthBadge' import { AuthBadge } from '@/auth/AuthBadge'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch' import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
import { VersionBadge } from '@/components/version/VersionBadge'
import { SearchInput } from '@/ui/components/search-input' import { SearchInput } from '@/ui/components/search-input'
import { Menu } from 'lucide-react' import { Menu } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
const LANG_OPTIONS = [
{ id: 'ru-RU', label: 'RU' },
{ id: 'en-US', label: 'EN' },
]
/** /**
* TopBar — sticky header (h=56) per handoff design. * TopBar — sticky header (h=56) per handoff design.
* *
@@ -17,7 +24,7 @@ import { useEffect, useRef, useState } from 'react'
* - Right: VersionBadge · ThemeSwitch · LanguageSwitch · Docs · AuthBadge * - Right: VersionBadge · ThemeSwitch · LanguageSwitch · Docs · AuthBadge
*/ */
export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) { export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [searchValue, setSearchValue] = useState('') const [searchValue, setSearchValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -115,42 +122,34 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
/> />
</form> </form>
{/* Right cluster — minimal per redesign prototype: {/* Right cluster — user prefers full set: Docs link + VersionBadge +
* - Lang toggle (single label, click flips) * ThemeSwitch + LanguageSwitch + AuthBadge (logged-in avatar/sign-in).
* - Theme switch (Earth / Dark) * User explicitly: "Руководство · dev · {commit} · theme · RU EN ·
* - AuthBadge (sign-in / user avatar) * zimin.an · logout это оставь". */}
* Docs link и VersionBadge перенесены в sidebar footer — diagnostic info,
* не critical UX. */}
<div className="md:ml-0 ml-auto flex items-center gap-2 shrink-0"> <div className="md:ml-0 ml-auto flex items-center gap-2 shrink-0">
<LanguageToggle /> <a
href="/docs/"
target="_blank"
rel="noreferrer noopener"
className="hidden sm:inline text-cell text-ink-2 hover:text-ink transition-colors"
>
{t('nav.docs')}
</a>
<span className="hidden md:inline-flex">
<VersionBadge />
</span>
<ThemeSwitch /> <ThemeSwitch />
<LanguageSwitch
value={i18n.language}
options={LANG_OPTIONS}
onChange={(id) => i18n.changeLanguage(id)}
/>
<AuthBadge /> <AuthBadge />
</div> </div>
</header> </header>
) )
} }
/**
* LanguageToggle — single-button toggle per redesign prototype (показывает
* текущий язык как label, click переключает на следующий). Замена tri/dual
* radio LanguageSwitch который занимал ~80px горизонтально.
*/
function LanguageToggle() {
const { i18n } = useTranslation()
const cur = i18n.language?.toLowerCase().startsWith('en') ? 'en' : 'ru'
const next = cur === 'ru' ? 'en-US' : 'ru-RU'
return (
<button
type="button"
onClick={() => i18n.changeLanguage(next)}
aria-label={`Language: ${cur.toUpperCase()}, click to switch`}
className="h-8 px-3 rounded-md border border-line bg-surface text-cap font-semibold tracking-[0.14em] text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors"
>
{cur.toUpperCase()}
</button>
)
}
type Crumb = { label: string; to?: string; subtitle?: string } type Crumb = { label: string; to?: string; subtitle?: string }
/** /**
+2 -2
View File
@@ -338,7 +338,7 @@ i18n
'scope.PUBLIC': 'PUBLIC — публичный', 'scope.PUBLIC': 'PUBLIC — публичный',
'scope.INTERNAL': 'INTERNAL — внутренний', 'scope.INTERNAL': 'INTERNAL — внутренний',
'scope.RESTRICTED': 'RESTRICTED — ограниченный', 'scope.RESTRICTED': 'RESTRICTED — ограниченный',
'schema.action.create': 'Создать справочник', 'schema.action.create': 'Создать',
'schema.action.edit': 'Изменить справочник', 'schema.action.edit': 'Изменить справочник',
'schema.action.editSchema': 'Схема', 'schema.action.editSchema': 'Схема',
'schema.tabs.metadata': 'Метаданные', 'schema.tabs.metadata': 'Метаданные',
@@ -844,7 +844,7 @@ i18n
'scope.PUBLIC': 'PUBLIC', 'scope.PUBLIC': 'PUBLIC',
'scope.INTERNAL': 'INTERNAL', 'scope.INTERNAL': 'INTERNAL',
'scope.RESTRICTED': 'RESTRICTED', 'scope.RESTRICTED': 'RESTRICTED',
'schema.action.create': 'Create dictionary', 'schema.action.create': 'Create',
'schema.action.edit': 'Edit dictionary', 'schema.action.edit': 'Edit dictionary',
'schema.action.editSchema': 'Schema', 'schema.action.editSchema': 'Schema',
'schema.tabs.metadata': 'Metadata', 'schema.tabs.metadata': 'Metadata',