Files
pipu-music/apps/web/src/pages/CollectionPage.tsx
T
mrleo1nidandClaude Opus 5.5 1c4e209e86
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Successful in 1m39s
feat(web): align the web UI with the design prototype
- Ambient background in the app shell: three drifting blurred blobs,
  the third one tinted with the current track's cover; can be turned off
  in settings and stays still with reduced motion.
- Island player variant (floating glass panel) next to the bar, chosen
  in settings; player source line ("Album · …", "My Wave"), reactions
  around the transport controls, queue drawer.
- Sidebar: like / dislike counters, pending uploads badge on Queue,
  admin Settings page holding the upload queue limits.
- Collection: header tile with kicker, underline tabs, time / number
  lead column with an equalizer for the current track, album column,
  dimmed dislikes with undo, Play all / Shuffle pills.
- Album: cover-tinted header and Play / Shuffle; artist: artist wave,
  Play and popular tracks (new GET /artists/{id}/top-tracks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:13:24 +03:00

212 lines
8.4 KiB
TypeScript

import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { TrackList, type Track } from '../entities/catalog';
import { useCollectionCounts } from '../entities/collection';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
import c from './catalog.module.css';
import s from './page.module.css';
export type CollectionTab = 'history' | 'likes' | 'dislikes';
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
const HERO: Record<CollectionTab, { icon: string; tile: string }> = {
history: { icon: historyFillIcon, tile: 'linear-gradient(140deg, #35D3FF, #2F80FF 60%, #1B3FA8)' },
likes: { icon: likeFillIcon, tile: 'linear-gradient(140deg, #5E9BFF, #2F80FF 50%, #7C5CFF)' },
dislikes: { icon: dislikeFillIcon, tile: 'linear-gradient(140deg, #4A5572, #1A2236)' },
};
export function CollectionPage({ tab }: { tab: CollectionTab }) {
const { t } = useTranslation();
const counts = useCollectionCounts();
const hero = HERO[tab];
const sub =
tab === 'likes'
? t('collection.sub_likes', { count: counts.likes ?? 0 })
: t(tab === 'history' ? 'collection.sub_history' : 'collection.sub_dislikes');
return (
<div className={s.page}>
<header className={c.colHero}>
<span className={c.tile} style={{ background: hero.tile }} aria-hidden>
<Icon src={hero.icon} size={52} />
</span>
<div className={c.heroText}>
<span className={c.kicker}>{t('collection.title')}</span>
<h1 className={c.colTitle}>{t(`collection.${tab}`)}</h1>
<span className={c.heroSub}>{sub}</span>
</div>
</header>
<div className={c.underlineTabs} role="tablist" aria-label={t('collection.title')}>
{TABS.map((k) => (
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.underlineTab}>
{t(`collection.${k}`)}
</Link>
))}
</div>
{tab === 'history' ? <History /> : <Reacted reaction={tab === 'likes' ? 'like' : 'dislike'} />}
</div>
);
}
/** Ключ дня в локальном времени: история группируется на клиенте. */
function dayKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
function dayTitle(iso: string, locale: string, t: (k: 'collection.today' | 'collection.yesterday') => string): string {
const d = new Date(iso);
const today = new Date();
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
if (dayKey(iso) === dayKey(today.toISOString())) return t('collection.today');
if (dayKey(iso) === dayKey(yesterday.toISOString())) return t('collection.yesterday');
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(d);
}
function History() {
const { t, i18n } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [confirm, setConfirm] = useState(false);
const history = useInfiniteQuery({
queryKey: ['history'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET('/api/v1/me/history', { params: { query: { cursor: pageParam, limit: 100 } } })),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const clear = useMutation({
mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/history')),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history'] }),
});
const items = history.data?.pages.flatMap((p) => p.items) ?? [];
const groups: { key: string; title: string; items: typeof items }[] = [];
for (const it of items) {
const key = dayKey(it.played_at);
const last = groups.at(-1);
if (last?.key === key) last.items.push(it);
else groups.push({ key, title: dayTitle(it.played_at, i18n.language, t), items: [it] });
}
const time = new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit' });
return (
<>
{items.length > 0 && (
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setConfirm(true)}>
{t('collection.clear_history')}
</Button>
</div>
)}
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
{groups.map((g) => (
<section key={g.key} className={c.section}>
<h2 className={c.day}>{g.title}</h2>
<TrackList
tracks={g.items.map((i) => i.track)}
showAlbum
context={{ type: 'history' }}
lead={(_, i) => time.format(new Date(g.items[i]!.played_at))}
/>
</section>
))}
{history.hasNextPage && (
<Button className={s.more} busy={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>
{t('collection.more')}
</Button>
)}
{confirm && (
<Dialog
title={t('collection.clear_history')}
onClose={() => setConfirm(false)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirm(false)}>
{t('form.cancel')}
</Button>
<Button
variant="danger"
onClick={() => {
setConfirm(false);
clear.mutate();
}}
>
{t('collection.clear_history')}
</Button>
</>
}
>
<p className={s.note}>{t('collection.clear_confirm')}</p>
</Dialog>
)}
</>
);
}
function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const errorText = useErrorText();
const path = reaction === 'like' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
const list = useInfiniteQuery({
queryKey: [reaction === 'like' ? 'likes' : 'dislikes'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET(path, { params: { query: { cursor: pageParam, limit: 100 } } })),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const tracks: Track[] = list.data?.pages.flatMap((p) => p.items.map((i) => i.track)) ?? [];
const total = list.data?.pages[0]?.total ?? 0;
const context = { type: 'collection' as const, id: reaction === 'like' ? 'likes' : 'dislikes' };
return (
<>
{reaction === 'like' && tracks.length > 0 && (
<div className={s.toolbar}>
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
{t('collection.play_all')}
</Button>
<Button
className={c.pill}
icon={shuffleIcon}
onClick={() => player.playList([...tracks].sort(() => Math.random() - 0.5), 0, context)}
>
{t('collection.shuffle')}
</Button>
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
</div>
)}
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
)}
{tracks.length > 0 && (
<TrackList
tracks={tracks}
showAlbum
context={context}
lead={(_, i) => String(i + 1)}
disliked={reaction === 'dislike'}
/>
)}
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('collection.more')}
</Button>
)}
</>
);
}