Merge branch 'fix/topbar-restore-search-single-x' into 'main'
fix(ui): restore TopBar search + notifications bell + theme to sidebar See merge request 2-6/2-6-4/terravault/ordinis!91
This commit is contained in:
@@ -16,6 +16,7 @@ import { useAuth } from 'react-oidc-context'
|
|||||||
import { useCanMutate } from '@/auth/useCanMutate'
|
import { useCanMutate } from '@/auth/useCanMutate'
|
||||||
import { useDictionaries, useMyDrafts } from '@/api/queries'
|
import { useDictionaries, useMyDrafts } from '@/api/queries'
|
||||||
import { Sheet, SheetContent } from '@/ui'
|
import { Sheet, SheetContent } from '@/ui'
|
||||||
|
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,20 +239,22 @@ function SidebarFooter({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Language toggle row (только когда expanded). */}
|
{/* Language toggle + Theme switch row (только когда expanded).
|
||||||
|
* ThemeSwitch и LanguageSwitch переехали из TopBar per user
|
||||||
|
* ("свитч темы можно тоже в профиль перенести"). */}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="border-t border-line px-3 py-2 flex items-center gap-2 shrink-0">
|
<div className="border-t border-line px-3 py-2 flex items-center gap-2 shrink-0">
|
||||||
<span className="text-cap text-mute">
|
|
||||||
{t('sidebar.lang', { defaultValue: 'Язык' })}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => i18n.changeLanguage(next)}
|
onClick={() => i18n.changeLanguage(next)}
|
||||||
className="ml-auto h-7 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors"
|
className="h-7 px-2.5 rounded-md border border-line bg-surface text-cap text-ink-2 hover:bg-surface-2 hover:text-ink transition-colors"
|
||||||
aria-label={`Language: ${cur.toUpperCase()}, click to switch`}
|
aria-label={`Language: ${cur.toUpperCase()}, click to switch`}
|
||||||
>
|
>
|
||||||
{cur.toUpperCase()}
|
{cur.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<ThemeSwitch />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
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 { useDictionaries, useDictionaryDetail } from '@/api/queries'
|
import { useDictionaries, useDictionaryDetail } from '@/api/queries'
|
||||||
import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
|
|
||||||
import { VersionBadge } from '@/components/version/VersionBadge'
|
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 { Bell, Menu } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,28 +20,24 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isCatalog = location.pathname === '/dictionaries'
|
const isCatalog = location.pathname === '/dictionaries'
|
||||||
// Catalog mode: bind input to URL ?q= (live filter). Else: local state submitting к /search.
|
|
||||||
const catalogQ = isCatalog
|
const catalogQ = isCatalog
|
||||||
? ((location.search as Record<string, unknown>)?.q as string | undefined) ?? ''
|
? ((location.search as Record<string, unknown>)?.q as string | undefined) ?? ''
|
||||||
: ''
|
: ''
|
||||||
const [searchValue, setSearchValue] = useState(catalogQ)
|
const [searchValue, setSearchValue] = useState(catalogQ)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const breadcrumb = useBreadcrumb()
|
||||||
|
|
||||||
// Sync local state с URL когда navigate на /dictionaries (catalog).
|
// Sync local input value c URL `?q=` when navigating between routes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSearchValue(catalogQ)
|
setSearchValue(catalogQ)
|
||||||
}, [catalogQ])
|
}, [catalogQ])
|
||||||
|
|
||||||
const breadcrumb = useBreadcrumb()
|
// ⌘K / Ctrl+K focus shortcut.
|
||||||
|
|
||||||
// ⌘K / Ctrl+K shortcut — focus search input from anywhere. Skip когда
|
|
||||||
// активен другой input/textarea (юзер печатает в record edit form).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||||||
// Уже в input — пусть default browser shortcut работает.
|
|
||||||
if (target === inputRef.current) return
|
if (target === inputRef.current) return
|
||||||
}
|
}
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -55,10 +50,8 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Context-aware search:
|
// Context-aware search:
|
||||||
// - На /dictionaries (catalog) каждый keystroke обновляет URL `?q=` чтобы
|
// - На /dictionaries — live filter каталога через URL `?q=`
|
||||||
// filter работал live. Submit (Enter) — no-op (фильтр уже applied).
|
// - На остальных routes — local state, submit redirect'ит на /search
|
||||||
// - На остальных routes input — local state, submit redirect'ит на /search
|
|
||||||
// с records search.
|
|
||||||
const handleSearchInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSearchInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const v = e.target.value
|
const v = e.target.value
|
||||||
setSearchValue(v)
|
setSearchValue(v)
|
||||||
@@ -78,7 +71,7 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const q = searchValue.trim()
|
const q = searchValue.trim()
|
||||||
if (q.length === 0) return
|
if (q.length === 0) return
|
||||||
if (isCatalog) return // На catalog уже live filter, submit не нужен.
|
if (isCatalog) return
|
||||||
void navigate({ to: '/search', search: { q } })
|
void navigate({ to: '/search', search: { q } })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +144,9 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Search — center, max-width per handoff */}
|
{/* Search center — context-aware (catalog mode = live filter, else =
|
||||||
|
* records search submit). Двойной X fixed в SearchInput component
|
||||||
|
* (type="text" вместо "search" — убрал native browser X). */}
|
||||||
<form onSubmit={handleSearchSubmit} className="hidden md:block ml-auto w-full max-w-[280px]">
|
<form onSubmit={handleSearchSubmit} className="hidden md:block ml-auto w-full max-w-[280px]">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -168,9 +163,10 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Right cluster — Docs + VersionBadge + ThemeSwitch only.
|
{/* Right cluster — broadcast slot per user: "руководство и версию
|
||||||
* AuthBadge + LanguageSwitch переехали в Sidebar footer per user
|
* оставить в топбар чтобы подсвечивать новинки. туда же и сообщения".
|
||||||
* feedback ("вход в сайдбар перенесем", "ru/en в сайдбаре"). */}
|
* Docs + VersionBadge + Notifications. ThemeSwitch / Lang / Auth
|
||||||
|
* переехали в Sidebar footer. */}
|
||||||
<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
|
<a
|
||||||
href="/docs/"
|
href="/docs/"
|
||||||
@@ -183,12 +179,32 @@ export function TopBar({ onMenuClick }: { onMenuClick?: () => void }) {
|
|||||||
<span className="hidden md:inline-flex">
|
<span className="hidden md:inline-flex">
|
||||||
<VersionBadge />
|
<VersionBadge />
|
||||||
</span>
|
</span>
|
||||||
<ThemeSwitch />
|
<NotificationsBell />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder notifications bell — будет показывать N unread сообщений
|
||||||
|
* (webhook errors, draft reviews, system notices). Click → drawer/popup.
|
||||||
|
* Сейчас static bell без counter — wire'ится когда backend notifications
|
||||||
|
* stream появится.
|
||||||
|
*/
|
||||||
|
function NotificationsBell() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||||||
|
aria-label={t('topbar.notifications', { defaultValue: 'Уведомления' })}
|
||||||
|
className="size-7 rounded-md text-ink-2 hover:text-ink hover:bg-surface-2 inline-flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<Bell size={14} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type Crumb = {
|
type Crumb = {
|
||||||
label: string
|
label: string
|
||||||
to?: string
|
to?: string
|
||||||
|
|||||||
@@ -231,10 +231,12 @@ function DictionariesPage() {
|
|||||||
{/* Toolbar — top section of the panel, tinted bg + bottom border.
|
{/* Toolbar — top section of the panel, tinted bg + bottom border.
|
||||||
* Узкая: px-3 py-1.5 (~6px vertical) — per user feedback ("бар поужать"). */}
|
* Узкая: px-3 py-1.5 (~6px vertical) — per user feedback ("бар поужать"). */}
|
||||||
<div className="flex flex-wrap items-center gap-2 bg-surface-2 border-b border-line px-3 py-1.5">
|
<div className="flex flex-wrap items-center gap-2 bg-surface-2 border-b border-line px-3 py-1.5">
|
||||||
{/* Catalog search — filter dicts by name/displayName/description. */}
|
{/* Catalog search — filter dicts by name/displayName/description.
|
||||||
|
* type="text" (не "search") чтобы избежать native X — мы рендерим
|
||||||
|
* свой clear button если нужно. */}
|
||||||
<div className="flex-1 min-w-[200px] max-w-xs">
|
<div className="flex-1 min-w-[200px] max-w-xs">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="text"
|
||||||
value={search.q ?? ''}
|
value={search.q ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSearch({ q: e.target.value || undefined })
|
setSearch({ q: e.target.value || undefined })
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ export const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
|
|||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="search"
|
// type="text" вместо "search" — native browser X (Chrome/Safari)
|
||||||
|
// дублировал custom onClear X справа ("два крестика"). Custom X
|
||||||
|
// имеет правильный onClick handler и tracks state, native — нет.
|
||||||
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
className={cn('pl-8', hasValue && onClear && 'pr-8', className)}
|
className={cn('pl-8', hasValue && onClear && 'pr-8', className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user