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 {