mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-03 07:21:34 -07:00
feat(anime): add anime browser powered by Aniyomi extensions
- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached - Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config - Support searching all sources at once with streaming, per-source results and status - Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese - Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon - Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
This commit is contained in:
@@ -43,6 +43,7 @@
|
||||
## Task 1: 365d range — backend type extension
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/core/services/immersion-tracker/query-trends.ts:16` and `src/core/services/immersion-tracker/query-trends.ts:84-88`
|
||||
- Test: `src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||
|
||||
@@ -101,13 +102,14 @@
|
||||
## Task 2: 365d range — server route allow-list
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/core/services/stats-server.ts` (search for trends route handler — look for `/api/stats/trends` or `getTrendsDashboard`)
|
||||
- Test: `src/core/services/__tests__/stats-server.test.ts`
|
||||
|
||||
- [ ] **Step 1: Locate the trends route in `stats-server.ts`**
|
||||
|
||||
Run: `grep -n 'trends\|TrendRange' src/core/services/stats-server.ts`
|
||||
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there *is* an allow-list (e.g. a `validRanges` array), continue.
|
||||
Read the surrounding code. If the route delegates straight through to `tracker.getTrendsDashboard(range, groupBy)` without an allow-list, **this entire task is a no-op** — skip ahead to Task 3 and document in the commit message of Task 3 that no server changes were needed. If there _is_ an allow-list (e.g. a `validRanges` array), continue.
|
||||
|
||||
- [ ] **Step 2: Add a failing test for `range=365d`**
|
||||
|
||||
@@ -145,6 +147,7 @@
|
||||
## Task 3: 365d range — frontend client and selector
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/lib/api-client.ts`
|
||||
- Modify: `stats/src/lib/api-client.test.ts`
|
||||
- Modify: `stats/src/hooks/useTrends.ts:5`
|
||||
@@ -175,10 +178,13 @@
|
||||
- [ ] **Step 6: Add `365d` to the `DateRangeSelector` segmented control**
|
||||
|
||||
In `stats/src/components/trends/DateRangeSelector.tsx:56`, change:
|
||||
|
||||
```tsx
|
||||
options={['7d', '30d', '90d', 'all'] as TimeRange[]}
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```tsx
|
||||
options={['7d', '30d', '90d', '365d', 'all'] as TimeRange[]}
|
||||
```
|
||||
@@ -206,6 +212,7 @@
|
||||
## Task 4: Vocabulary Top 50 — collapse word/reading column
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/vocabulary/FrequencyRankTable.tsx:110-144`
|
||||
- Test: create `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` if not present (check first with `ls stats/src/components/vocabulary/`)
|
||||
|
||||
@@ -217,6 +224,7 @@
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create or extend `stats/src/components/vocabulary/FrequencyRankTable.test.tsx` with:
|
||||
|
||||
```tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
@@ -271,6 +279,7 @@
|
||||
Replace the `<th>Reading</th>` header column and the corresponding `<td>` in the body. The new shape:
|
||||
|
||||
Header (around line 113-119):
|
||||
|
||||
```tsx
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
@@ -283,6 +292,7 @@
|
||||
```
|
||||
|
||||
Body row (around line 122-141):
|
||||
|
||||
```tsx
|
||||
<tr
|
||||
key={w.wordId}
|
||||
@@ -297,16 +307,10 @@
|
||||
{(() => {
|
||||
const reading = fullReading(w.headword, w.reading);
|
||||
if (!reading || reading === w.headword) return null;
|
||||
return (
|
||||
<span className="text-ctp-subtext0 text-xs ml-1.5">
|
||||
【{reading}】
|
||||
</span>
|
||||
);
|
||||
return <span className="text-ctp-subtext0 text-xs ml-1.5">【{reading}】</span>;
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">{w.partOfSpeech && <PosBadge pos={w.partOfSpeech} />}</td>
|
||||
<td className="py-1.5 text-right font-mono tabular-nums text-ctp-blue text-xs">
|
||||
{w.frequency}x
|
||||
</td>
|
||||
@@ -336,6 +340,7 @@
|
||||
## Task 5: Episode detail — filter Anki-deleted cards
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/anime/EpisodeDetail.tsx:109-147`
|
||||
- Test: create `stats/src/components/anime/EpisodeDetail.test.tsx` if not present
|
||||
|
||||
@@ -410,11 +415,13 @@
|
||||
Then change the JSX iteration from `cardEvents.map(...)` to `filteredCardEvents.map(...)` (one occurrence around line 113), and after the `</div>` closing the cards-mined section, add:
|
||||
|
||||
```tsx
|
||||
{hiddenCardCount > 0 && (
|
||||
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
|
||||
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
hiddenCardCount > 0 && (
|
||||
<div className="px-3 pb-3 -mt-1 text-[10px] text-ctp-overlay2 italic">
|
||||
{hiddenCardCount} card{hiddenCardCount === 1 ? '' : 's'} hidden (deleted from Anki)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Place that footer immediately before the closing `</div>` of the bordered cards-mined section, so it stays scoped to that block.
|
||||
@@ -422,12 +429,14 @@
|
||||
**Important:** the filter only fires once `noteInfos` has been populated. While `noteInfos` is still empty (initial load before the second fetch resolves), every card with noteIds would be filtered out — that's wrong. Guard the filter so that it only runs after the noteInfos fetch has completed. The simplest signal: track `noteInfosLoaded: boolean` next to `noteInfos`, set it `true` in the `.then` callback, and only apply filtering when `noteInfosLoaded || allNoteIds.length === 0`.
|
||||
|
||||
Concrete change near line 22:
|
||||
|
||||
```tsx
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||
const [noteInfosLoaded, setNoteInfosLoaded] = useState(false);
|
||||
```
|
||||
|
||||
Inside the existing `useEffect` (around line 36-46), set the loaded flag:
|
||||
|
||||
```tsx
|
||||
if (allNoteIds.length > 0) {
|
||||
getStatsClient()
|
||||
@@ -452,6 +461,7 @@
|
||||
```
|
||||
|
||||
And gate the filter:
|
||||
|
||||
```tsx
|
||||
const filteredCardEvents = noteInfosLoaded
|
||||
? cardEvents
|
||||
@@ -496,6 +506,7 @@
|
||||
## Task 6: Library detail — delete episode action
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/library/MediaHeader.tsx`
|
||||
- Modify: `stats/src/components/library/MediaDetailView.tsx`
|
||||
- Modify: `stats/src/hooks/useMediaLibrary.ts`
|
||||
@@ -553,9 +564,7 @@
|
||||
|
||||
```tsx
|
||||
<div className="flex items-start gap-2">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">
|
||||
{detail.canonicalTitle}
|
||||
</h2>
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate flex-1">{detail.canonicalTitle}</h2>
|
||||
{onDeleteEpisode && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -718,6 +727,7 @@
|
||||
## Task 7: Library — collapsible series groups
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/library/LibraryTab.tsx`
|
||||
- Test: create `stats/src/components/library/LibraryTab.test.tsx`
|
||||
|
||||
@@ -758,11 +768,13 @@
|
||||
- [ ] **Step 3: Add collapsible state and toggle to `LibraryTab.tsx`**
|
||||
|
||||
Modify imports:
|
||||
|
||||
```tsx
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
```
|
||||
|
||||
Inside the component, after the existing `useState` calls:
|
||||
|
||||
```tsx
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
|
||||
|
||||
@@ -780,6 +792,7 @@
|
||||
```
|
||||
|
||||
Actually, the cleanest pattern is **initialize once on first data load via `useEffect`**:
|
||||
|
||||
```tsx
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
|
||||
const [hasInitializedCollapsed, setHasInitializedCollapsed] = useState(false);
|
||||
@@ -814,71 +827,71 @@
|
||||
Replace the section block (around line 64-115) so the header is a `<button>`:
|
||||
|
||||
```tsx
|
||||
{grouped.map((group) => {
|
||||
const isCollapsed = collapsedGroups.has(group.key);
|
||||
const isSingleVideo = group.items.length === 1;
|
||||
return (
|
||||
<section
|
||||
key={group.key}
|
||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isSingleVideo && toggleGroup(group.key)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-controls={`group-body-${group.key}`}
|
||||
disabled={isSingleVideo}
|
||||
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
|
||||
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
|
||||
}`}
|
||||
{
|
||||
grouped.map((group) => {
|
||||
const isCollapsed = collapsedGroups.has(group.key);
|
||||
const isSingleVideo = group.items.length === 1;
|
||||
return (
|
||||
<section
|
||||
key={group.key}
|
||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||
>
|
||||
{!isSingleVideo && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
|
||||
isCollapsed ? '' : 'rotate-90'
|
||||
}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isSingleVideo && toggleGroup(group.key)}
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-controls={`group-body-${group.key}`}
|
||||
disabled={isSingleVideo}
|
||||
className={`w-full flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40 text-left ${
|
||||
isSingleVideo ? '' : 'hover:bg-ctp-base/60 transition-colors cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{!isSingleVideo && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`text-xs text-ctp-overlay2 transition-transform shrink-0 ${
|
||||
isCollapsed ? '' : 'rotate-90'
|
||||
}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
)}
|
||||
<CoverImage
|
||||
videoId={group.items[0]!.videoId}
|
||||
title={group.title}
|
||||
src={group.imageUrl}
|
||||
className="w-16 h-16 rounded-2xl shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-ctp-text truncate">{group.title}</h3>
|
||||
</div>
|
||||
{group.subtitle ? (
|
||||
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
|
||||
) : null}
|
||||
<div className="text-xs text-ctp-overlay2 mt-2">
|
||||
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div id={`group-body-${group.key}`} className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{group.items.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CoverImage
|
||||
videoId={group.items[0]!.videoId}
|
||||
title={group.title}
|
||||
src={group.imageUrl}
|
||||
className="w-16 h-16 rounded-2xl shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-ctp-text truncate">
|
||||
{group.title}
|
||||
</h3>
|
||||
</div>
|
||||
{group.subtitle ? (
|
||||
<div className="text-xs text-ctp-overlay1 truncate mt-1">{group.subtitle}</div>
|
||||
) : null}
|
||||
<div className="text-xs text-ctp-overlay2 mt-2">
|
||||
{group.items.length} video{group.items.length !== 1 ? 's' : ''} ·{' '}
|
||||
{formatDuration(group.totalActiveMs)} · {formatNumber(group.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<div id={`group-body-${group.key}`} className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{group.items.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Watch out:** the previous header had a clickable `<a>` for the channel URL. Wrapping the whole header in a `<button>` makes nested anchors invalid. The simplest fix: drop the channel URL link from inside the header (it's still reachable from the individual `MediaCard`s), or move it to a separate row outside the button. Choose the first — minimum visual disruption.
|
||||
@@ -906,6 +919,7 @@
|
||||
## Task 8: Session grouping helper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `stats/src/lib/session-grouping.ts`
|
||||
- Create: `stats/src/lib/session-grouping.test.ts`
|
||||
|
||||
@@ -1012,7 +1026,9 @@
|
||||
|
||||
for (const session of sessions) {
|
||||
const hasVideoId =
|
||||
typeof session.videoId === 'number' && Number.isFinite(session.videoId) && session.videoId > 0;
|
||||
typeof session.videoId === 'number' &&
|
||||
Number.isFinite(session.videoId) &&
|
||||
session.videoId > 0;
|
||||
const key = hasVideoId ? `v-${session.videoId}` : `s-${session.sessionId}`;
|
||||
const existing = byVideo.get(key);
|
||||
if (existing) {
|
||||
@@ -1066,6 +1082,7 @@
|
||||
## Task 9: Sessions tab — episode rollup UI
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/components/sessions/SessionsTab.tsx`
|
||||
- Modify: `stats/src/lib/delete-confirm.ts` (add `confirmBucketDelete`)
|
||||
- Modify: `stats/src/lib/delete-confirm.test.ts`
|
||||
@@ -1161,114 +1178,120 @@
|
||||
Skeleton:
|
||||
|
||||
```tsx
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
|
||||
const buckets = groupSessionsByVideo(daySessions);
|
||||
return (
|
||||
<div key={dayLabel}>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{buckets.map((bucket) => {
|
||||
if (bucket.sessions.length === 1) {
|
||||
const s = bucket.sessions[0]!;
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
{
|
||||
Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
|
||||
const buckets = groupSessionsByVideo(daySessions);
|
||||
return (
|
||||
<div key={dayLabel}>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{buckets.map((bucket) => {
|
||||
if (bucket.sessions.length === 1) {
|
||||
const s = bucket.sessions[0]!;
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={bucket.key}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isOpen = expandedBuckets.has(bucket.key);
|
||||
return (
|
||||
<div key={bucket.key}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
<div
|
||||
key={bucket.key}
|
||||
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleBucket(bucket.key)}
|
||||
aria-expanded={isOpen}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">
|
||||
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{bucket.sessions.length} sessions · {formatDuration(bucket.totalActiveMs)} ·{' '}
|
||||
{bucket.totalCardsMined} cards
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteBucket(bucket);
|
||||
}}
|
||||
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
|
||||
title="Delete all sessions in this group"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="pl-8 pr-2 pb-2 space-y-2">
|
||||
{bucket.sessions.map((s) => {
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isOpen = expandedBuckets.has(bucket.key);
|
||||
return (
|
||||
<div key={bucket.key} className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleBucket(bucket.key)}
|
||||
aria-expanded={isOpen}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-left hover:bg-ctp-surface0/70 transition-colors"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`text-xs text-ctp-overlay2 transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||
>
|
||||
{'\u25B6'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ctp-text truncate">
|
||||
{bucket.representativeSession.canonicalTitle ?? 'Unknown Episode'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{bucket.sessions.length} sessions ·{' '}
|
||||
{formatDuration(bucket.totalActiveMs)} ·{' '}
|
||||
{bucket.totalCardsMined} cards
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteBucket(bucket);
|
||||
}}
|
||||
className="text-[10px] text-ctp-red/70 hover:text-ctp-red px-1.5 py-0.5 rounded hover:bg-ctp-red/10 transition-colors"
|
||||
title="Delete all sessions in this group"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="pl-8 pr-2 pb-2 space-y-2">
|
||||
{bucket.sessions.map((s) => {
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === s.sessionId ? null : s.sessionId)
|
||||
}
|
||||
onDelete={() => void handleDeleteSession(s)}
|
||||
deleteDisabled={deletingSessionId === s.sessionId}
|
||||
onNavigateToMediaDetail={onNavigateToMediaDetail}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail session={s} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container *outside* the toggle button. Pick the second option — it's accessible without role gymnastics:
|
||||
**Note on nested buttons:** the bucket header is a `<button>` and contains a "Delete" `<button>`. HTML disallows nested buttons. Switch the outer element to a `<div role="button" tabIndex={0} onClick={...} onKeyDown={...}>` instead, OR put the delete button in a wrapping flex container _outside_ the toggle button. Pick the second option — it's accessible without role gymnastics:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center">
|
||||
@@ -1281,7 +1304,7 @@
|
||||
</div>
|
||||
```
|
||||
|
||||
Use that pattern in the actual implementation. The skeleton above shows the *intent*; the final code must have sibling buttons, not nested ones.
|
||||
Use that pattern in the actual implementation. The skeleton above shows the _intent_; the final code must have sibling buttons, not nested ones.
|
||||
|
||||
Add `handleDeleteBucket`:
|
||||
|
||||
@@ -1336,6 +1359,7 @@
|
||||
## Task 10: Chart clarity pass
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `stats/src/lib/chart-theme.ts`
|
||||
- Modify: `stats/src/components/trends/TrendChart.tsx`
|
||||
- Modify: `stats/src/components/trends/StackedTrendChart.tsx`
|
||||
@@ -1528,6 +1552,7 @@
|
||||
## Task 11: Changelog fragment
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `changes/2026-04-09-stats-dashboard-feedback-pass.md`
|
||||
|
||||
- [ ] **Step 1: Read the existing changelog format**
|
||||
|
||||
Reference in New Issue
Block a user