- 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>
212 lines
8.4 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|