mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
- Added hover-revealed ↗ button on SessionRow that navigates to the
anime media-detail view for the session's videoId
- Added `sessions` origin type to MediaDetailOrigin and
openSessionsMediaDetail() / closeMediaDetail() handling so the
back button returns correctly to the Sessions tab ("Back to Sessions")
- Wired onNavigateToMediaDetail down through SessionsTab → SessionRow
- Excluded tokens with MeCab POS3 = 助動詞語幹 (e.g. そうだ grammar tails)
from subtitle annotation metadata so frequency, JLPT, and N+1 styling
no longer apply to grammar-tail tokens
- Added annotation-stage unit test and end-to-end tokenizeSubtitle test
for the そうだ exclusion path
- Updated docs-site changelog, immersion-tracking, and
subtitle-annotations pages to reflect both changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
141 lines
4.7 KiB
TypeScript
141 lines
4.7 KiB
TypeScript
import { useState } from 'react';
|
|
import { BASE_URL } from '../../lib/api-client';
|
|
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
|
|
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
|
import type { SessionSummary } from '../../types/stats';
|
|
|
|
interface SessionRowProps {
|
|
session: SessionSummary;
|
|
isExpanded: boolean;
|
|
detailsId: string;
|
|
onToggle: () => void;
|
|
onDelete: () => void;
|
|
deleteDisabled?: boolean;
|
|
onNavigateToMediaDetail?: (videoId: number) => void;
|
|
}
|
|
|
|
function CoverThumbnail({
|
|
animeId,
|
|
videoId,
|
|
title,
|
|
}: {
|
|
animeId: number | null;
|
|
videoId: number | null;
|
|
title: string;
|
|
}) {
|
|
const [failed, setFailed] = useState(false);
|
|
const fallbackChar = title.charAt(0) || '?';
|
|
|
|
if ((!animeId && !videoId) || failed) {
|
|
return (
|
|
<div className="w-10 h-14 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-sm 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=""
|
|
loading="lazy"
|
|
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface2"
|
|
onError={() => setFailed(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function SessionRow({
|
|
session,
|
|
isExpanded,
|
|
detailsId,
|
|
onToggle,
|
|
onDelete,
|
|
deleteDisabled = false,
|
|
onNavigateToMediaDetail,
|
|
}: SessionRowProps) {
|
|
const displayWordCount = getSessionDisplayWordCount(session);
|
|
const knownWordsSeen = session.knownWordsSeen;
|
|
|
|
return (
|
|
<div className="relative group">
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
aria-expanded={isExpanded}
|
|
aria-controls={detailsId}
|
|
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 pr-12 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
|
>
|
|
<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">tokens</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-ctp-green font-medium font-mono tabular-nums">
|
|
{formatNumber(knownWordsSeen)}
|
|
</div>
|
|
<div className="text-ctp-overlay2">known words</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`text-ctp-blue text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
|
>
|
|
{'\u25B8'}
|
|
</div>
|
|
</button>
|
|
{onNavigateToMediaDetail != null && session.videoId != null ? (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onNavigateToMediaDetail(session.videoId!);
|
|
}}
|
|
aria-label={`View overview for ${session.canonicalTitle ?? 'Unknown Media'}`}
|
|
className="absolute right-10 top-1/2 -translate-y-1/2 w-5 h-5 rounded border border-ctp-surface2 text-transparent hover:border-ctp-blue/50 hover:text-ctp-blue hover:bg-ctp-blue/10 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 flex items-center justify-center"
|
|
title="View anime overview"
|
|
>
|
|
{'\u2197'}
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
disabled={deleteDisabled}
|
|
aria-label={`Delete session ${session.canonicalTitle ?? 'Unknown Media'}`}
|
|
className="absolute right-3 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>
|
|
);
|
|
}
|