mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Replace every user-facing "token(s)" label, tooltip, and message in the stats UI with "words" so the terminology is consistent and friendlier (e.g. "Words Seen", "word occurrences", "3.4 / 100 words", "Words Today"). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
formatDuration,
|
|
formatRelativeDate,
|
|
formatNumber,
|
|
formatSessionDayLabel,
|
|
} from '../../lib/formatters';
|
|
import { BASE_URL } from '../../lib/api-client';
|
|
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
|
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
|
|
import type { SessionSummary } from '../../types/stats';
|
|
|
|
interface RecentSessionsProps {
|
|
sessions: SessionSummary[];
|
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
|
onNavigateToSession: (sessionId: number) => void;
|
|
onDeleteSession: (session: SessionSummary) => void;
|
|
onDeleteDayGroup: (dayLabel: string, daySessions: SessionSummary[]) => void;
|
|
onDeleteAnimeGroup: (sessions: SessionSummary[]) => void;
|
|
deletingIds: Set<number>;
|
|
}
|
|
|
|
interface AnimeGroup {
|
|
key: string;
|
|
animeId: number | null;
|
|
animeTitle: string | null;
|
|
videoId: number | null;
|
|
sessions: SessionSummary[];
|
|
totalCards: number;
|
|
totalWords: number;
|
|
totalActiveMs: number;
|
|
totalKnownWords: number;
|
|
}
|
|
|
|
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
|
const groups = new Map<string, SessionSummary[]>();
|
|
|
|
for (const session of sessions) {
|
|
const dayLabel = formatSessionDayLabel(session.startedAtMs);
|
|
const group = groups.get(dayLabel);
|
|
if (group) {
|
|
group.push(session);
|
|
} else {
|
|
groups.set(dayLabel, [session]);
|
|
}
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
|
const map = new Map<string, AnimeGroup>();
|
|
|
|
for (const session of sessions) {
|
|
const key =
|
|
session.animeId != null
|
|
? `anime-${session.animeId}`
|
|
: session.videoId != null
|
|
? `video-${session.videoId}`
|
|
: `session-${session.sessionId}`;
|
|
|
|
const existing = map.get(key);
|
|
const displayWordCount = getSessionDisplayWordCount(session);
|
|
if (existing) {
|
|
existing.sessions.push(session);
|
|
existing.totalCards += session.cardsMined;
|
|
existing.totalWords += displayWordCount;
|
|
existing.totalActiveMs += session.activeWatchedMs;
|
|
existing.totalKnownWords += session.knownWordsSeen;
|
|
} else {
|
|
map.set(key, {
|
|
key,
|
|
animeId: session.animeId,
|
|
animeTitle: session.animeTitle,
|
|
videoId: session.videoId,
|
|
sessions: [session],
|
|
totalCards: session.cardsMined,
|
|
totalWords: displayWordCount,
|
|
totalActiveMs: session.activeWatchedMs,
|
|
totalKnownWords: session.knownWordsSeen,
|
|
});
|
|
}
|
|
}
|
|
|
|
return Array.from(map.values());
|
|
}
|
|
|
|
function CoverThumbnail({
|
|
animeId,
|
|
videoId,
|
|
title,
|
|
}: {
|
|
animeId: number | null;
|
|
videoId: number | null;
|
|
title: string;
|
|
}) {
|
|
const fallbackChar = title.charAt(0) || '?';
|
|
const [isFallback, setIsFallback] = useState(false);
|
|
|
|
if ((!animeId && !videoId) || isFallback) {
|
|
return (
|
|
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
|
|
{fallbackChar}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const src =
|
|
animeId != null
|
|
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
|
|
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
|
|
|
return (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
|
|
onError={() => setIsFallback(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function SessionItem({
|
|
session,
|
|
onNavigateToMediaDetail,
|
|
onNavigateToSession,
|
|
onDelete,
|
|
deleteDisabled,
|
|
}: {
|
|
session: SessionSummary;
|
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
|
onNavigateToSession: (sessionId: number) => void;
|
|
onDelete: () => void;
|
|
deleteDisabled: boolean;
|
|
}) {
|
|
const displayWordCount = getSessionDisplayWordCount(session);
|
|
const navigationTarget = getSessionNavigationTarget(session);
|
|
|
|
return (
|
|
<div className="relative group">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (navigationTarget.type === 'media-detail') {
|
|
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
|
|
return;
|
|
}
|
|
onNavigateToSession(navigationTarget.sessionId);
|
|
}}
|
|
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left cursor-pointer"
|
|
>
|
|
<CoverThumbnail
|
|
animeId={session.animeId}
|
|
videoId={session.videoId}
|
|
title={session.canonicalTitle ?? 'Unknown'}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-ctp-text truncate">
|
|
{session.canonicalTitle ?? 'Unknown Media'}
|
|
</div>
|
|
<div className="text-xs text-ctp-overlay2">
|
|
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
|
|
active
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-4 text-xs text-center shrink-0">
|
|
<div>
|
|
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
|
{formatNumber(session.cardsMined)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">cards</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
|
{formatNumber(displayWordCount)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">words</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
|
{formatNumber(session.knownWordsSeen)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">known words</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
disabled={deleteDisabled}
|
|
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
|
title="Delete session"
|
|
>
|
|
{'\u2715'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AnimeGroupRow({
|
|
group,
|
|
onNavigateToMediaDetail,
|
|
onNavigateToSession,
|
|
onDeleteSession,
|
|
onDeleteAnimeGroup,
|
|
deletingIds,
|
|
}: {
|
|
group: AnimeGroup;
|
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
|
onNavigateToSession: (sessionId: number) => void;
|
|
onDeleteSession: (session: SessionSummary) => void;
|
|
onDeleteAnimeGroup: (group: AnimeGroup) => void;
|
|
deletingIds: Set<number>;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
|
|
|
|
if (group.sessions.length === 1) {
|
|
const s = group.sessions[0]!;
|
|
return (
|
|
<SessionItem
|
|
session={s}
|
|
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
|
onNavigateToSession={onNavigateToSession}
|
|
onDelete={() => onDeleteSession(s)}
|
|
deleteDisabled={deletingIds.has(s.sessionId)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media';
|
|
const mostRecentSession = group.sessions[0]!;
|
|
const disclosureId = `recent-sessions-${mostRecentSession.sessionId}`;
|
|
|
|
return (
|
|
<div className="group/anime">
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((value) => !value)}
|
|
aria-expanded={expanded}
|
|
aria-controls={disclosureId}
|
|
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-10 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
|
>
|
|
<CoverThumbnail
|
|
animeId={group.animeId}
|
|
videoId={mostRecentSession.videoId}
|
|
title={displayTitle}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
|
<div className="text-xs text-ctp-overlay2">
|
|
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-4 text-xs text-center shrink-0">
|
|
<div>
|
|
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
|
{formatNumber(group.totalCards)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">cards</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
|
{formatNumber(group.totalWords)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">words</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
|
{formatNumber(group.totalKnownWords)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">known words</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
|
aria-hidden="true"
|
|
>
|
|
{'\u25B8'}
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDeleteAnimeGroup(group)}
|
|
disabled={groupDeleting}
|
|
aria-label={`Delete all sessions for ${displayTitle}`}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/anime:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
|
title={`Delete all sessions for ${displayTitle}`}
|
|
>
|
|
{groupDeleting ? '\u2026' : '\u2715'}
|
|
</button>
|
|
</div>
|
|
{expanded && (
|
|
<div
|
|
id={disclosureId}
|
|
role="region"
|
|
aria-label={`${displayTitle} sessions`}
|
|
className="ml-6 mt-1 space-y-1"
|
|
>
|
|
{group.sessions.map((s) => {
|
|
const navigationTarget = getSessionNavigationTarget(s);
|
|
|
|
return (
|
|
<div key={s.sessionId} className="relative group/nested">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (navigationTarget.type === 'media-detail') {
|
|
onNavigateToMediaDetail(navigationTarget.videoId, navigationTarget.sessionId);
|
|
return;
|
|
}
|
|
onNavigateToSession(navigationTarget.sessionId);
|
|
}}
|
|
className="w-full bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 pr-10 flex items-center gap-3 hover:border-ctp-surface1 transition-colors text-left cursor-pointer"
|
|
>
|
|
<CoverThumbnail
|
|
animeId={s.animeId}
|
|
videoId={s.videoId}
|
|
title={s.canonicalTitle ?? 'Unknown'}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
|
{s.canonicalTitle ?? 'Unknown Media'}
|
|
</div>
|
|
<div className="text-xs text-ctp-overlay2">
|
|
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)}{' '}
|
|
active
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-4 text-xs text-center shrink-0">
|
|
<div>
|
|
<div className="text-ctp-cards-mined font-medium font-mono tabular-nums">
|
|
{formatNumber(s.cardsMined)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">cards</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-ctp-mauve font-medium font-mono tabular-nums">
|
|
{formatNumber(getSessionDisplayWordCount(s))}
|
|
</div>
|
|
<div className="text-ctp-overlay2">words</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
|
{formatNumber(s.knownWordsSeen)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">known words</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => onDeleteSession(s)}
|
|
disabled={deletingIds.has(s.sessionId)}
|
|
aria-label={`Delete session ${s.canonicalTitle ?? 'Unknown Media'}`}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-red/50 hover:text-ctp-red hover:bg-ctp-red/10 transition-colors opacity-0 group-hover/nested:opacity-100 focus:opacity-100 flex items-center justify-center disabled:opacity-40 disabled:cursor-not-allowed"
|
|
title="Delete session"
|
|
>
|
|
{'\u2715'}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RecentSessions({
|
|
sessions,
|
|
onNavigateToMediaDetail,
|
|
onNavigateToSession,
|
|
onDeleteSession,
|
|
onDeleteDayGroup,
|
|
onDeleteAnimeGroup,
|
|
deletingIds,
|
|
}: RecentSessionsProps) {
|
|
if (sessions.length === 0) {
|
|
return (
|
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
|
<div className="text-sm text-ctp-overlay2">No sessions yet</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const groups = groupSessionsByDay(sessions);
|
|
const anyDeleting = deletingIds.size > 0;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
|
|
const animeGroups = groupSessionsByAnime(daySessions);
|
|
const groupDeleting = daySessions.some((s) => deletingIds.has(s.sessionId));
|
|
return (
|
|
<div key={dayLabel} className="group/day">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-widest shrink-0">
|
|
{dayLabel}
|
|
</h3>
|
|
<div className="flex-1 h-px bg-gradient-to-r from-ctp-surface1 to-transparent" />
|
|
<button
|
|
type="button"
|
|
onClick={() => onDeleteDayGroup(dayLabel, daySessions)}
|
|
disabled={anyDeleting}
|
|
aria-label={`Delete all sessions from ${dayLabel}`}
|
|
className="shrink-0 text-xs text-transparent hover:text-ctp-red transition-colors opacity-0 group-hover/day:opacity-100 focus:opacity-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
title={`Delete all sessions from ${dayLabel}`}
|
|
>
|
|
{groupDeleting ? '\u2026' : '\u2715'}
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{animeGroups.map((group) => (
|
|
<AnimeGroupRow
|
|
key={group.key}
|
|
group={group}
|
|
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
|
onNavigateToSession={onNavigateToSession}
|
|
onDeleteSession={onDeleteSession}
|
|
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
|
|
deletingIds={deletingIds}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|