mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
fix(ci): add changelog fragment for immersion changes
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
|
||||
|
||||
interface CoverImageProps {
|
||||
videoId: number;
|
||||
title: string;
|
||||
src?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CoverImage({ videoId, title, className = '' }: CoverImageProps) {
|
||||
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [resolvedSrc]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
@@ -23,8 +29,9 @@ export function CoverImage({ videoId, title, className = '' }: CoverImageProps)
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
|
||||
src={resolvedSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import { groupMediaLibraryItems } from '../../lib/media-library-grouping';
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaDetailView } from './MediaDetailView';
|
||||
|
||||
@@ -16,8 +18,18 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return media;
|
||||
const q = search.toLowerCase();
|
||||
return media.filter((m) => m.canonicalTitle.toLowerCase().includes(q));
|
||||
return media.filter((m) => {
|
||||
const haystacks = [
|
||||
m.canonicalTitle,
|
||||
m.videoTitle,
|
||||
m.channelName,
|
||||
m.uploaderId,
|
||||
m.channelId,
|
||||
].filter(Boolean);
|
||||
return haystacks.some((value) => value!.toLowerCase().includes(q));
|
||||
});
|
||||
}, [media, search]);
|
||||
const grouped = useMemo(() => groupMediaLibraryItems(filtered), [filtered]);
|
||||
|
||||
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
||||
|
||||
@@ -26,7 +38,6 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
<MediaDetailView
|
||||
videoId={selectedVideoId}
|
||||
onBack={() => setSelectedVideoId(null)}
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -45,20 +56,63 @@ export function LibraryTab({ onNavigateToSession }: LibraryTabProps) {
|
||||
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"
|
||||
/>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} title{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
|
||||
{grouped.length} channel{grouped.length !== 1 ? 's' : ''} · {filtered.length} video
|
||||
{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filtered.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{grouped.map((group) => (
|
||||
<section
|
||||
key={group.key}
|
||||
className="rounded-2xl border border-ctp-surface1 bg-ctp-surface0/70 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-4 p-4 border-b border-ctp-surface1 bg-ctp-base/40">
|
||||
<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">
|
||||
{group.channelUrl ? (
|
||||
<a
|
||||
href={group.channelUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-base font-semibold text-ctp-text truncate hover:text-ctp-blue transition-colors"
|
||||
>
|
||||
{group.title}
|
||||
</a>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
<div 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
|
||||
import type { MediaLibraryItem } from '../../types/stats';
|
||||
|
||||
interface MediaCardProps {
|
||||
@@ -17,10 +18,14 @@ export function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
<CoverImage
|
||||
videoId={item.videoId}
|
||||
title={item.canonicalTitle}
|
||||
src={resolveMediaArtworkUrl(item, 'video')}
|
||||
className="w-full aspect-[3/4] rounded-t-lg"
|
||||
/>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
|
||||
{item.videoTitle && item.videoTitle !== item.canonicalTitle ? (
|
||||
<div className="text-xs text-ctp-subtext1 truncate mt-1">{item.videoTitle}</div>
|
||||
) : null}
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { buildLookupRateDisplay } from '../../lib/yomitan-lookup';
|
||||
import { resolveMediaArtworkUrl } from '../../lib/media-library-grouping';
|
||||
import type { MediaDetailData } from '../../types/stats';
|
||||
|
||||
interface MediaHeaderProps {
|
||||
@@ -45,10 +46,27 @@ export function MediaHeader({ detail, initialKnownWordsSummary = null }: MediaHe
|
||||
<CoverImage
|
||||
videoId={detail.videoId}
|
||||
title={detail.canonicalTitle}
|
||||
src={resolveMediaArtworkUrl(detail, 'video')}
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
{detail.channelName ? (
|
||||
<div className="mt-1 text-sm text-ctp-subtext1 truncate">
|
||||
{detail.channelUrl ? (
|
||||
<a
|
||||
href={detail.channelUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-ctp-blue transition-colors"
|
||||
>
|
||||
{detail.channelName}
|
||||
</a>
|
||||
) : (
|
||||
detail.channelName
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-2 mt-3 text-sm">
|
||||
<div>
|
||||
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>
|
||||
|
||||
Reference in New Issue
Block a user