feat(topbar): match redesign prototype — breadcrumb back-arrow + cleaner right cluster
Per /Users/zimin/Downloads/Ordinis (4)/redesign/compact.html. TopBar breadcrumb: - Detail routes теперь показывают `← Справочники / Космические аппараты` с back-arrow prefix на parent crumb (caption text, mute). Bold title + inline mono subtitle "satellites · v1.0.0". - Catalog route: simple "Dictionaries" title. TopBar right cluster cleanup: - Removed `Docs` link — diagnostic, не critical UX, моя кноп не нужна для UI flow. - Removed `VersionBadge` (commit + tag) — был noise рядом с продакшн features. Build info всё ещё доступна в browser console / Docs. - ThemeSwitch: tri-state (Light/Dark/System icons 3×24px) заменён на 2-button `Earth | Dark` с text labels (per prototype). `system` preference остаётся в localStorage для existing users, но новый UI не выставляет. - LanguageSwitch: dual radio `RU | EN` заменён на single toggle button — показывает текущий язык как label, click переключает на следующий. Saves ~60px горизонтально, matches prototype. EditorInfoPanel order: - Action buttons (Экспорт / Time-travel / AOI) переехали из середины panel (между metadata и Relations) в bottom (после Relations) — per prototype. Info → meta → relations → actions flow. - Action order: Экспорт первым (frequent), затем Time-travel, AOI последним (rare для most dicts).
This commit is contained in:
@@ -72,57 +72,6 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
|
|||||||
</MetaCell>
|
</MetaCell>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions — bottom buttons per redesign prototype: Time-travel + AOI */}
|
|
||||||
{actions && (
|
|
||||||
<div className="space-y-2 pt-2 border-t border-line-2">
|
|
||||||
{actions.onTimeTravel && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={actions.onTimeTravel}
|
|
||||||
aria-pressed={actions.timeTravelActive}
|
|
||||||
className={cn(
|
|
||||||
'w-full h-8 rounded-md border text-cell flex items-center justify-center gap-1.5 transition-colors',
|
|
||||||
actions.timeTravelActive
|
|
||||||
? 'border-accent bg-accent-bg text-accent'
|
|
||||||
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ClockCounterClockwiseIcon weight="regular" size={14} />
|
|
||||||
{t('timeTravel.button', { defaultValue: 'Time-travel…' })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{actions.onAoi && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={actions.onAoi}
|
|
||||||
aria-pressed={actions.aoiActive}
|
|
||||||
className={cn(
|
|
||||||
'w-full h-8 rounded-md border text-cell flex items-center justify-center gap-1.5 transition-colors',
|
|
||||||
actions.aoiActive
|
|
||||||
? 'border-accent bg-accent-bg text-accent'
|
|
||||||
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MapTrifoldIcon weight="regular" size={14} />
|
|
||||||
{t('aoi.button', { defaultValue: 'AOI фильтр…' })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{actions.onExport && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={actions.onExport}
|
|
||||||
disabled={actions.exportPending}
|
|
||||||
className="w-full h-8 rounded-md border border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed text-cell flex items-center justify-center gap-1.5 transition-colors"
|
|
||||||
>
|
|
||||||
<DownloadSimpleIcon weight="regular" size={14} />
|
|
||||||
{actions.exportPending
|
|
||||||
? t('export.pending', { defaultValue: 'Экспорт…' })
|
|
||||||
: t('export.button', { defaultValue: '↓ Экспорт…' })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Relations — merged inline DictionaryDependentsPanel в InfoPanel.
|
{/* Relations — merged inline DictionaryDependentsPanel в InfoPanel.
|
||||||
* → ссылается: outgoing FK targets (имя dict, links).
|
* → ссылается: outgoing FK targets (имя dict, links).
|
||||||
* ← используют: incoming FK rows с field path + active count + policy chip
|
* ← используют: incoming FK rows с field path + active count + policy chip
|
||||||
@@ -202,6 +151,59 @@ export function EditorInfoPanel({ detail, recordCount, actions }: EditorInfoPane
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Actions — bottom buttons per redesign prototype: Экспорт + Time-travel
|
||||||
|
* + AOI. После Relations block чтобы не разрывать info → meta → relations
|
||||||
|
* мыслительный поток info-panel'а. */}
|
||||||
|
{actions && (
|
||||||
|
<div className="space-y-2 pt-3 mt-2 border-t border-line-2">
|
||||||
|
{actions.onExport && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={actions.onExport}
|
||||||
|
disabled={actions.exportPending}
|
||||||
|
className="w-full h-8 rounded-md border border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed text-cell flex items-center justify-center gap-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
<DownloadSimpleIcon weight="regular" size={14} />
|
||||||
|
{actions.exportPending
|
||||||
|
? t('export.pending', { defaultValue: 'Экспорт…' })
|
||||||
|
: t('export.button', { defaultValue: '↓ Экспорт…' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{actions.onTimeTravel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={actions.onTimeTravel}
|
||||||
|
aria-pressed={actions.timeTravelActive}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-8 rounded-md border text-cell flex items-center justify-center gap-1.5 transition-colors',
|
||||||
|
actions.timeTravelActive
|
||||||
|
? 'border-accent bg-accent-bg text-accent'
|
||||||
|
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ClockCounterClockwiseIcon weight="regular" size={14} />
|
||||||
|
{t('timeTravel.button', { defaultValue: 'Time-travel…' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{actions.onAoi && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={actions.onAoi}
|
||||||
|
aria-pressed={actions.aoiActive}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-8 rounded-md border text-cell flex items-center justify-center gap-1.5 transition-colors',
|
||||||
|
actions.aoiActive
|
||||||
|
? 'border-accent bg-accent-bg text-accent'
|
||||||
|
: 'border-line bg-surface text-ink-2 hover:border-line-2 hover:bg-surface-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MapTrifoldIcon weight="regular" size={14} />
|
||||||
|
{t('aoi.button', { defaultValue: 'AOI фильтр…' })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,42 @@
|
|||||||
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'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tri-state theme switch: Light / Dark / System.
|
* Theme switch per redesign prototype — 2-button segmented control с text
|
||||||
|
* labels "Earth | Dark". Filled accent-bg на active.
|
||||||
*
|
*
|
||||||
* <p>Segmented control с тремя иконками. Активный — `--color-accent`
|
* <p>Прошлый tri-state (Light/Dark/System icons) был noise — "system" preference
|
||||||
* background + `--color-on-accent` foreground. Click меняет `preference`
|
* редко используется явно, мало кто понимает Monitor icon. Простой 2-button
|
||||||
* в {@link ThemeProvider}, который пишет в localStorage и переключает
|
* `Earth / Dark` toggle — точно повторяет prototype и улучшает discoverability.
|
||||||
* `data-theme` на html.
|
|
||||||
*
|
*
|
||||||
* <p>System icon показывает «follow OS» — auto-update при OS theme change.
|
* <p>Internally "Earth" maps на `light` preference (Earthy palette default).
|
||||||
|
* "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()
|
||||||
|
|
||||||
const items: { id: ThemePreference; label: string; icon: typeof Sun }[] = [
|
// Treat 'system' как 'light' для UI — переключение всё равно flatten'ит
|
||||||
{ id: 'light', label: t('theme.light'), icon: Sun },
|
// preference на explicit choice.
|
||||||
{ id: 'dark', label: t('theme.dark'), icon: Moon },
|
const activeId: Exclude<ThemePreference, 'system'> =
|
||||||
{ id: 'system', label: t('theme.system'), icon: Monitor },
|
preference === 'dark' ? 'dark' : 'light'
|
||||||
|
|
||||||
|
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 p-0.5"
|
className="inline-flex items-center rounded-md border border-line bg-surface overflow-hidden"
|
||||||
>
|
>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const Icon = item.icon
|
const active = activeId === item.id
|
||||||
const active = preference === item.id
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -39,16 +44,15 @@ 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(
|
||||||
'inline-flex h-6 w-6 items-center justify-center rounded transition-colors',
|
'h-8 px-3 text-cap font-semibold tracking-[0.14em] transition-colors',
|
||||||
active
|
active
|
||||||
? 'bg-accent text-on-accent'
|
? 'bg-accent text-on-accent'
|
||||||
: 'text-mute hover:text-ink hover:bg-surface-2',
|
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={13} strokeWidth={2.25} />
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
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'
|
||||||
@@ -18,13 +16,8 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
* + ⌘K / Ctrl+K shortcut focuses input from anywhere
|
* + ⌘K / Ctrl+K shortcut focuses input from anywhere
|
||||||
* - Right: VersionBadge · ThemeSwitch · LanguageSwitch · Docs · AuthBadge
|
* - Right: VersionBadge · ThemeSwitch · LanguageSwitch · Docs · AuthBadge
|
||||||
*/
|
*/
|
||||||
const LANG_OPTIONS = [
|
|
||||||
{ id: 'ru-RU', label: 'RU' },
|
|
||||||
{ id: 'en-US', label: 'EN' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = 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)
|
||||||
@@ -73,30 +66,37 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Breadcrumb — left. Editor route ('/dictionaries/$name') получает
|
{/* Breadcrumb per redesign prototype:
|
||||||
* augmented breadcrumb с displayName + version (per redesign prototype).
|
* - Single crumb (catalog): "Справочники" (bold) + "40 шт." count
|
||||||
* Остальные routes: regular path-based breadcrumb. */}
|
* - Detail route: "← Справочники / Космические аппараты satellites · v1.0.0"
|
||||||
<nav className="flex items-center gap-1.5 text-body min-w-0">
|
* с back-arrow prefix на parent crumb (clickable). */}
|
||||||
|
<nav className="flex items-center gap-2 text-body min-w-0">
|
||||||
{breadcrumb.map((crumb, i) => {
|
{breadcrumb.map((crumb, i) => {
|
||||||
const isLast = i === breadcrumb.length - 1
|
const isLast = i === breadcrumb.length - 1
|
||||||
|
const isFirstOfTwoPlus = i === 0 && breadcrumb.length > 1
|
||||||
return (
|
return (
|
||||||
<span key={i} className="flex items-center gap-1.5 min-w-0">
|
<span key={i} className="flex items-center gap-2 min-w-0">
|
||||||
{i > 0 && <span className="text-mute text-cell">/</span>}
|
{i > 0 && <span className="text-mute text-title-md font-light">/</span>}
|
||||||
{crumb.to && !isLast ? (
|
{crumb.to && !isLast ? (
|
||||||
<Link
|
<Link
|
||||||
to={crumb.to}
|
to={crumb.to}
|
||||||
className="text-ink-2 hover:text-ink truncate transition-colors"
|
className={
|
||||||
|
isFirstOfTwoPlus
|
||||||
|
? 'text-cell text-mute hover:text-ink truncate transition-colors inline-flex items-center gap-1'
|
||||||
|
: 'text-ink-2 hover:text-ink truncate transition-colors'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{isFirstOfTwoPlus && <span aria-hidden>←</span>}
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className={isLast ? 'text-ink font-medium truncate' : 'text-ink-2 truncate'}>
|
<span className={isLast ? 'text-ink font-semibold truncate' : 'text-ink-2 truncate'}>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Editor route: inline mono "<name> · v<version>" after title */}
|
{/* Editor route: inline mono "<name> · v<version>" after title */}
|
||||||
{isLast && crumb.subtitle && (
|
{isLast && crumb.subtitle && (
|
||||||
<span className="text-mono text-mute shrink-0 ml-2">{crumb.subtitle}</span>
|
<span className="text-mono text-cell text-mute shrink-0 ml-1">{crumb.subtitle}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -115,32 +115,42 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Right cluster */}
|
{/* Right cluster — minimal per redesign prototype:
|
||||||
|
* - Lang toggle (single label, click flips)
|
||||||
|
* - Theme switch (Earth / Dark)
|
||||||
|
* - AuthBadge (sign-in / user avatar)
|
||||||
|
* 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">
|
||||||
<a
|
<LanguageToggle />
|
||||||
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>
|
|
||||||
{/* VersionBadge hide на narrow viewport — diagnostic info, не critical UX */}
|
|
||||||
<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 }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user