feat(web): storage admin, search and catalog pages (M2)
- Storage admin: local folders with a folder picker and NFS sources, connection test, scan and full scan with live progress, enable, disable, delete - path pattern editor with reorder, examples and a preview table that shows which pattern matched and what came from tags; compile errors land under the broken pattern - Library page with counters, scans and unreadable files; search reindex for admins - search box in the top bar: combobox with keyboard navigation, 150 ms debounce, cancelled stale requests, "/" and Ctrl+K, layout fix hint - search page with tabs and paging, artist and album pages, album cards and track lists; unavailable tracks are dimmed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1160cccd3
commit
a685b1b7ca
27 files changed
+2505
-9
No files matched your search
@@ -13,6 +13,7 @@
|
||||
grid-area: top;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
@@ -21,9 +22,17 @@
|
||||
}
|
||||
|
||||
.logoLink {
|
||||
width: 196px;
|
||||
flex: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.logoLink {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.topRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -9,15 +9,20 @@ import {
|
||||
groupIcon,
|
||||
homeFillIcon,
|
||||
homeIcon,
|
||||
libraryFillIcon,
|
||||
libraryIcon,
|
||||
logoutIcon,
|
||||
musicIcon,
|
||||
settingsIcon,
|
||||
storageFillIcon,
|
||||
storageIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
|
||||
type NavPath = '/' | '/admin/users' | '/admin/audit';
|
||||
type NavPath = '/' | '/admin/users' | '/admin/audit' | '/admin/sources' | '/admin/library';
|
||||
|
||||
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
|
||||
return (
|
||||
@@ -51,6 +56,7 @@ function Shell() {
|
||||
<Link to="/" className={s.logoLink}>
|
||||
<Logo />
|
||||
</Link>
|
||||
<SearchBox />
|
||||
<div className={s.topRight}>
|
||||
<Menu
|
||||
label={t('profile.menu')}
|
||||
@@ -82,6 +88,10 @@ function Shell() {
|
||||
<>
|
||||
<div className={s.navSection}>{t('nav.admin')}</div>
|
||||
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
|
||||
)}
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
{user.role === 'admin' && (
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
.box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 40px 0 42px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line2);
|
||||
color: var(--text3);
|
||||
font: 12px var(--font-mono);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input:focus ~ .kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 45;
|
||||
max-height: min(70vh, 560px);
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--elev);
|
||||
border: 1px solid var(--line2);
|
||||
box-shadow: var(--shadow);
|
||||
animation: pipuIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.corrected {
|
||||
margin: 4px 10px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
margin: 8px 10px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.optionText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optionTitle {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optionSub {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nothing {
|
||||
padding: 16px 10px;
|
||||
color: var(--text3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.all {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
margin-top: 6px;
|
||||
border-radius: 10px;
|
||||
color: var(--accent2);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.all:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.kbd {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover } from '../entities/catalog';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { searchIcon } from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import s from './SearchBox.module.css';
|
||||
|
||||
const DEBOUNCE_MS = 150;
|
||||
|
||||
type Item = {
|
||||
key: string;
|
||||
group: 'artists' | 'albums' | 'tracks';
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cover: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
||||
round?: boolean;
|
||||
go: () => void;
|
||||
};
|
||||
|
||||
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
|
||||
export function SearchBox() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const navigate = useNavigate();
|
||||
const listId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [q, setQ] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [q]);
|
||||
|
||||
// «/» или Ctrl+K — фокус в поиск (если пользователь не печатает в другом поле).
|
||||
useEffect(() => {
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const typing = target.closest('input, textarea, select, [contenteditable="true"]');
|
||||
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const suggest = useQuery({
|
||||
queryKey: ['suggest', debounced],
|
||||
enabled: debounced !== '',
|
||||
placeholderData: keepPreviousData,
|
||||
// signal отменяет устаревший запрос, когда пользователь печатает дальше.
|
||||
queryFn: async ({ signal }) =>
|
||||
unwrap(await session.client.GET('/api/v1/search/suggest', { params: { query: { q: debounced } }, signal })),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const data = debounced ? suggest.data : undefined;
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setActive(-1);
|
||||
};
|
||||
const goSearch = () => {
|
||||
const query = q.trim();
|
||||
if (!query) return;
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
void navigate({ to: '/search', search: { q: query } });
|
||||
};
|
||||
|
||||
const items: Item[] = [];
|
||||
if (data) {
|
||||
for (const a of data.artists)
|
||||
items.push({
|
||||
key: `ar-${a.id}`,
|
||||
group: 'artists',
|
||||
title: a.name,
|
||||
subtitle: t('search.artist_kind'),
|
||||
cover: { id: a.id },
|
||||
round: true,
|
||||
go: () => void navigate({ to: '/artist/$id', params: { id: a.id } }),
|
||||
});
|
||||
for (const al of data.albums)
|
||||
items.push({
|
||||
key: `al-${al.id}`,
|
||||
group: 'albums',
|
||||
title: al.title,
|
||||
subtitle: [al.artist.name, al.year].filter(Boolean).join(' · '),
|
||||
cover: al,
|
||||
go: () => void navigate({ to: '/album/$id', params: { id: al.id } }),
|
||||
});
|
||||
for (const tr of data.tracks)
|
||||
items.push({
|
||||
key: `tr-${tr.id}`,
|
||||
group: 'tracks',
|
||||
title: tr.title,
|
||||
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
||||
cover: tr.album,
|
||||
// Воспроизведение — M3; пока трек открывает свой альбом.
|
||||
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
|
||||
});
|
||||
}
|
||||
|
||||
const choose = (i: number) => {
|
||||
const it = items[i];
|
||||
if (!it) return goSearch();
|
||||
close();
|
||||
setQ('');
|
||||
inputRef.current?.blur();
|
||||
it.go();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setActive((i) => Math.min(items.length - 1, i + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive((i) => Math.max(-1, i - 1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
choose(active);
|
||||
} else if (e.key === 'Escape') {
|
||||
close();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const showList = open && debounced !== '' && data !== undefined;
|
||||
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
||||
const optionId = (i: number) => `${listId}-${i}`;
|
||||
|
||||
return (
|
||||
<div className={s.box}>
|
||||
<Icon src={searchIcon} size={18} className={s.icon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
className={s.input}
|
||||
value={q}
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.label')}
|
||||
role="combobox"
|
||||
aria-expanded={showList}
|
||||
aria-controls={listId}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={showList && active >= 0 ? optionId(active) : undefined}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
setOpen(true);
|
||||
setActive(-1);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(close, 120)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<kbd className={s.kbd} aria-hidden>
|
||||
/
|
||||
</kbd>
|
||||
{showList && (
|
||||
<div className={s.popover}>
|
||||
{data.corrected_query && (
|
||||
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
||||
)}
|
||||
<div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
||||
{groups.map((g) => {
|
||||
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
||||
if (groupItems.length === 0) return null;
|
||||
return (
|
||||
<div key={g} role="group" aria-label={t(`search.${g}`)}>
|
||||
<div className={s.groupTitle}>{t(`search.${g}`)}</div>
|
||||
{groupItems.map(({ it, i }) => (
|
||||
<div
|
||||
key={it.key}
|
||||
id={optionId(i)}
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
className={cx(s.option, i === active && s.active)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => choose(i)}
|
||||
>
|
||||
<Cover album={it.cover} size={36} round={it.round} />
|
||||
<span className={s.optionText}>
|
||||
<span className={s.optionTitle}>{it.title}</span>
|
||||
<span className={s.optionSub}>{it.subtitle}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={s.all}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={goSearch}
|
||||
>
|
||||
{t('search.show_all')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user