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
@@ -0,0 +1,164 @@
|
||||
.cover {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.artistChip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
margin: -10px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.artistChip:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.artistChip {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.cardCover {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.disc {
|
||||
margin: 14px 12px 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.track {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 56px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.track:has(.trackAlbum) {
|
||||
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.unavailable {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.num {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.trackMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trackArtists,
|
||||
.trackAlbum {
|
||||
font-size: 13px;
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.track:has(.trackAlbum) {
|
||||
grid-template-columns: 40px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.trackAlbum {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { albumIcon, personIcon } from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
import s from './catalog.module.css';
|
||||
|
||||
export type Track = components['schemas']['Track'];
|
||||
export type AlbumSummary = components['schemas']['AlbumSummary'];
|
||||
export type ArtistRef = components['schemas']['ArtistRef'];
|
||||
|
||||
const WAVE = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
/** Детерминированная палитра по id, пока у альбома нет своей обложки. */
|
||||
function fallbackColors(id: string): string[] {
|
||||
let h = 0;
|
||||
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
const base = h % 360;
|
||||
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
|
||||
}
|
||||
|
||||
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
|
||||
export function Cover({
|
||||
album,
|
||||
size = 48,
|
||||
round,
|
||||
className,
|
||||
}: {
|
||||
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
||||
size?: number | string;
|
||||
round?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const colors = album.cover_colors?.length === 3 ? album.cover_colors : album.id ? fallbackColors(album.id) : WAVE;
|
||||
return (
|
||||
<span
|
||||
className={cx(s.cover, round && s.round, className)}
|
||||
style={{
|
||||
width: size,
|
||||
// Резиновая обложка (size="100%") — высоту задаёт aspect-ratio карточки.
|
||||
height: typeof size === 'number' ? size : undefined,
|
||||
background: `radial-gradient(circle at 30% 25%, ${colors[2]}, transparent 60%), linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{album.cover_url ? (
|
||||
<img src={album.cover_url} alt="" loading="lazy" />
|
||||
) : (
|
||||
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Исполнители через запятую, каждый — ссылка. */
|
||||
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
||||
return (
|
||||
<>
|
||||
{artists.map((a, i) => (
|
||||
<Fragment key={a.id}>
|
||||
{i > 0 && ', '}
|
||||
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
|
||||
{a.name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
||||
return (
|
||||
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
||||
<Cover album={album} size="100%" className={s.cardCover} />
|
||||
<span className={s.cardTitle}>{album.title}</span>
|
||||
<span className={s.cardMeta}>
|
||||
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
|
||||
return (
|
||||
<div className={s.grid}>
|
||||
{albums.map((a) => (
|
||||
<AlbumCard key={a.id} album={a} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список треков. showAlbum — колонка альбома (поиск); иначе номер трека (страница альбома).
|
||||
* Воспроизведение появится в M3: пока строка только показывает трек.
|
||||
*/
|
||||
export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; showAlbum?: boolean; numbered?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||
let lastDisc: number | null = null;
|
||||
return (
|
||||
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
|
||||
{tracks.map((tr, i) => {
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||
lastDisc = disc;
|
||||
return (
|
||||
<Fragment key={tr.id}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
<div
|
||||
role="row"
|
||||
className={cx(s.track, !tr.available && s.unavailable)}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
>
|
||||
<span className={s.num}>{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}</span>
|
||||
<span className={s.trackMain}>
|
||||
<span className={s.trackTitle}>{tr.title}</span>
|
||||
<span className={s.trackArtists}>
|
||||
<ArtistLinks artists={tr.artists} />
|
||||
</span>
|
||||
</span>
|
||||
{showAlbum && (
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtistChip({ artist }: { artist: ArtistRef }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Link to="/artist/$id" params={{ id: artist.id }} className={s.artistChip}>
|
||||
<Cover album={{ id: artist.id }} size={88} round />
|
||||
<span className={s.cardTitle}>{artist.name}</span>
|
||||
<span className={s.cardMeta}>{t('search.artist_kind')}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user