mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
The stats dashboard now supports both anime series and YouTube videos
in the same library, so the anime-only copy no longer fits. Rename
user-visible labels ("Active Anime", "Search anime…", "Anime — Per
Day", "Episodes per Anime", "Words In Multiple Anime", etc.) to use
"Title"/"Library" wording that covers either source.
Data-model names (animeId, animeCount, useAnimeLibrary) stay as-is;
this pass only touches strings the user actually reads.
148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
|
|
import { formatDuration } from '../../lib/formatters';
|
|
import { AnimeCard } from './AnimeCard';
|
|
import { AnimeDetailView } from './AnimeDetailView';
|
|
|
|
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
|
type CardSize = 'sm' | 'md' | 'lg';
|
|
|
|
const GRID_CLASSES: Record<CardSize, string> = {
|
|
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
|
|
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
|
|
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
|
|
};
|
|
|
|
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
|
{ key: 'lastWatched', label: 'Last Watched' },
|
|
{ key: 'watchTime', label: 'Watch Time' },
|
|
{ key: 'cards', label: 'Cards' },
|
|
{ key: 'episodes', label: 'Episodes' },
|
|
];
|
|
|
|
function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) {
|
|
return [...list].sort((a, b) => {
|
|
switch (key) {
|
|
case 'lastWatched':
|
|
return b.lastWatchedMs - a.lastWatchedMs;
|
|
case 'watchTime':
|
|
return b.totalActiveMs - a.totalActiveMs;
|
|
case 'cards':
|
|
return b.totalCards - a.totalCards;
|
|
case 'episodes':
|
|
return b.episodeCount - a.episodeCount;
|
|
}
|
|
});
|
|
}
|
|
|
|
interface AnimeTabProps {
|
|
initialAnimeId?: number | null;
|
|
onClearInitialAnime?: () => void;
|
|
onNavigateToWord?: (wordId: number) => void;
|
|
onOpenEpisodeDetail?: (animeId: number, videoId: number) => void;
|
|
}
|
|
|
|
export function AnimeTab({
|
|
initialAnimeId,
|
|
onClearInitialAnime,
|
|
onNavigateToWord,
|
|
onOpenEpisodeDetail,
|
|
}: AnimeTabProps) {
|
|
const { anime, loading, error } = useAnimeLibrary();
|
|
const [search, setSearch] = useState('');
|
|
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
|
const [cardSize, setCardSize] = useState<CardSize>('md');
|
|
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (initialAnimeId != null) {
|
|
setSelectedAnimeId(initialAnimeId);
|
|
onClearInitialAnime?.();
|
|
}
|
|
}, [initialAnimeId, onClearInitialAnime]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const base = search.trim()
|
|
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
|
|
: anime;
|
|
return sortAnime(base, sortKey);
|
|
}, [anime, search, sortKey]);
|
|
|
|
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
|
|
|
if (selectedAnimeId !== null) {
|
|
return (
|
|
<AnimeDetailView
|
|
animeId={selectedAnimeId}
|
|
onBack={() => setSelectedAnimeId(null)}
|
|
onNavigateToWord={onNavigateToWord}
|
|
onOpenEpisodeDetail={
|
|
onOpenEpisodeDetail
|
|
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
|
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Search library..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
|
/>
|
|
<select
|
|
value={sortKey}
|
|
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
|
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
|
|
>
|
|
{SORT_OPTIONS.map((opt) => (
|
|
<option key={opt.key} value={opt.key}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="flex bg-ctp-surface0 rounded-lg p-0.5 border border-ctp-surface1 shrink-0">
|
|
{(['sm', 'md', 'lg'] as const).map((size) => (
|
|
<button
|
|
key={size}
|
|
onClick={() => setCardSize(size)}
|
|
className={`px-2 py-1 rounded-md text-xs transition-colors ${
|
|
cardSize === size
|
|
? 'bg-ctp-surface2 text-ctp-text shadow-sm'
|
|
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
|
}`}
|
|
>
|
|
{size === 'sm' ? '▪' : size === 'md' ? '◼' : '⬛'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="text-xs text-ctp-overlay2 shrink-0">
|
|
{filtered.length} titles · {formatDuration(totalMs)}
|
|
</div>
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="text-sm text-ctp-overlay2 p-4">No titles found</div>
|
|
) : (
|
|
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
|
{filtered.map((item) => (
|
|
<AnimeCard
|
|
key={item.animeId}
|
|
anime={item}
|
|
onClick={() => setSelectedAnimeId(item.animeId)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|