diff --git a/src/frontend/src/components/ContentCard.tsx b/src/frontend/src/components/ContentCard.tsx new file mode 100644 index 0000000..82c9e10 --- /dev/null +++ b/src/frontend/src/components/ContentCard.tsx @@ -0,0 +1,273 @@ +import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react'; +import { StatusBadge } from './StatusBadge'; +import { DownloadProgressBar } from './DownloadProgressBar'; +import { useDownloadProgress } from '../contexts/DownloadProgressContext'; +import type { ContentItem } from '@shared/types/index'; + +// ── Helpers ── + +function formatDuration(seconds: number | null): string { + if (seconds == null) return ''; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +function formatRelativeTime(isoString: string | null): string { + if (!isoString) return ''; + const delta = Date.now() - Date.parse(isoString); + if (delta < 0) return 'just now'; + const seconds = Math.floor(delta / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + return `${Math.floor(months / 12)}y ago`; +} + +// ── Component ── + +interface ContentCardProps { + item: ContentItem; + selected: boolean; + onSelect: (id: number) => void; + onToggleMonitored: (id: number, monitored: boolean) => void; + onDownload: (id: number) => void; +} + +export function ContentCard({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentCardProps) { + const progress = useDownloadProgress(item.id); + const duration = formatDuration(item.duration); + const published = formatRelativeTime(item.publishedAt); + + return ( +
onSelect(item.id)} + onMouseEnter={(e) => { + if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)'; + }} + onMouseLeave={(e) => { + if (!selected) e.currentTarget.style.borderColor = 'var(--border)'; + }} + > + {/* Thumbnail */} +
+ {item.thumbnailUrl ? ( + + ) : ( +
+ {item.contentType === 'audio' ? : } +
+ )} + + {/* Duration badge */} + {duration && ( + + {duration} + + )} + + {/* Selection checkbox */} +
+ { + e.stopPropagation(); + onSelect(item.id); + }} + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${item.title}`} + style={{ + width: 18, + height: 18, + cursor: 'pointer', + accentColor: 'var(--accent)', + }} + /> +
+ + {/* Download progress overlay */} + {item.status === 'downloading' && progress && ( +
+ +
+ )} +
+ + {/* Card body */} +
+ {/* Title */} + e.stopPropagation()} + style={{ + display: 'block', + fontWeight: 500, + fontSize: 'var(--font-size-sm)', + color: 'var(--text-primary)', + lineHeight: 1.4, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + textDecoration: 'none', + }} + title={item.title} + > + {item.title} + + + {/* Meta row */} +
+ + + {published} + +
+ + {/* Action row */} +
+ + + {item.status !== 'downloaded' && item.status !== 'downloading' && item.status !== 'queued' && ( + + )} + + e.stopPropagation()} + title="Open on YouTube" + aria-label={`Open ${item.title} on YouTube`} + style={{ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 28, + height: 28, + borderRadius: 'var(--radius-sm)', + color: 'var(--text-muted)', + transition: 'color var(--transition-fast)', + }} + > + + +
+
+
+ ); +} diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index 9d50cd9..478459a 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -10,6 +10,8 @@ import { Download, ExternalLink, Film, + Grid3X3, + List, ListMusic, Loader, Music, @@ -29,6 +31,7 @@ import { StatusBadge } from '../components/StatusBadge'; import { QualityLabel } from '../components/QualityLabel'; import { DownloadProgressBar } from '../components/DownloadProgressBar'; import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; +import { ContentCard } from '../components/ContentCard'; import { Pagination } from '../components/Pagination'; import { Modal } from '../components/Modal'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; @@ -108,6 +111,10 @@ export function ChannelDetail() { const [contentTypeFilter, setContentTypeFilter] = useState(''); const [sortKey, setSortKey] = useState(null); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [viewMode, setViewMode] = useState<'table' | 'card'>(() => { + try { return (localStorage.getItem('tubearr-content-view') as 'table' | 'card') || 'table'; } + catch { return 'table'; } + }); const contentFilters: ChannelContentFilters = useMemo(() => ({ page: contentPage, @@ -280,6 +287,14 @@ export function ChannelDetail() { setContentPage(1); }, []); + const handleViewToggle = useCallback(() => { + setViewMode((prev) => { + const next = prev === 'table' ? 'card' : 'table'; + try { localStorage.setItem('tubearr-content-view', next); } catch { /* ignore */ } + return next; + }); + }, []); + const togglePlaylist = useCallback((id: number | 'uncategorized') => { setExpandedPlaylists((prev) => { const next = new Set(prev); @@ -614,6 +629,37 @@ export function ChannelDetail() { [contentColumns, sortKey, sortDirection, handleSort], ); + const renderCardGrid = useCallback( + (items: ContentItem[]) => ( +
+ {items.length === 0 ? ( +
+ No content found for this channel. +
+ ) : ( + items.map((item) => ( + toggleMonitored.mutate({ contentId: id, monitored })} + onDownload={(id) => downloadContent.mutate(id)} + /> + )) + )} +
+ ), + [selectedIds, toggleSelect, toggleMonitored, downloadContent], + ); + const renderPlaylistGroups = useCallback( (groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => (
@@ -1117,6 +1163,27 @@ export function ChannelDetail() { + {/* View toggle */} +
{contentError ? (
) : hasPlaylistGroups ? ( renderPlaylistGroups(playlistGroups!) + ) : viewMode === 'card' ? ( + renderCardGrid(content) ) : ( renderTable(content) )} diff --git a/src/frontend/src/styles/global.css b/src/frontend/src/styles/global.css index 1763619..f028da8 100644 --- a/src/frontend/src/styles/global.css +++ b/src/frontend/src/styles/global.css @@ -216,6 +216,12 @@ tbody tr:hover { background-color: var(--bg-hover); } +/* ── Card checkbox visibility on hover ── */ +div:hover > .card-checkbox, +.card-checkbox:has(input:checked) { + opacity: 1 !important; +} + /* ── Responsive ── */ @media (max-width: 768px) { :root {