feat(S03/T01): content card view with grid layout and view toggle
- Created ContentCard component: 16:9 thumbnail with duration badge, title, status badge, relative time, monitor/download/external actions, overlay checkbox for selection (visible on hover + when selected) - Added card/table view toggle button in Content header (Grid3X3/List icons) - View preference persisted to localStorage (tubearr-content-view) - Card grid uses CSS grid with auto-fill minmax(240px, 1fr) - Card hover shows border-light highlight, selected shows accent border - Download progress overlay on card thumbnail for active downloads - Added .card-checkbox CSS rule for hover-reveal behavior - Both views share pagination, search, filter, bulk selection
This commit is contained in:
parent
91b0b74dcb
commit
a0906f3cdb
3 changed files with 348 additions and 0 deletions
273
src/frontend/src/components/ContentCard.tsx
Normal file
273
src/frontend/src/components/ContentCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
backgroundColor: selected ? 'var(--bg-selected)' : 'var(--bg-card-solid)',
|
||||||
|
border: selected ? '1px solid var(--accent)' : '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-xl)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => 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 */}
|
||||||
|
<div style={{ position: 'relative', aspectRatio: '16/9', backgroundColor: 'var(--bg-input)' }}>
|
||||||
|
{item.thumbnailUrl ? (
|
||||||
|
<img
|
||||||
|
src={item.thumbnailUrl}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.contentType === 'audio' ? <Music size={32} /> : <Film size={32} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Duration badge */}
|
||||||
|
{duration && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 6,
|
||||||
|
right: 6,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 'var(--font-size-xs)',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selection checkbox */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 6,
|
||||||
|
left: 6,
|
||||||
|
opacity: selected ? 1 : 0,
|
||||||
|
transition: 'opacity var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
className="card-checkbox"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(item.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Select ${item.title}`}
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
cursor: 'pointer',
|
||||||
|
accentColor: 'var(--accent)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download progress overlay */}
|
||||||
|
{item.status === 'downloading' && progress && (
|
||||||
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}>
|
||||||
|
<DownloadProgressBar progress={progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card body */}
|
||||||
|
<div style={{ padding: 'var(--space-3)' }}>
|
||||||
|
{/* Title */}
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => 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}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginTop: 'var(--space-2)',
|
||||||
|
gap: 'var(--space-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
<span style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-xs)' }}>
|
||||||
|
{published}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
marginTop: 'var(--space-2)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleMonitored(item.id, !item.monitored);
|
||||||
|
}}
|
||||||
|
title={item.monitored ? 'Unmonitor' : 'Monitor'}
|
||||||
|
aria-label={item.monitored ? `Unmonitor ${item.title}` : `Monitor ${item.title}`}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: item.monitored ? 'var(--accent)' : 'var(--text-muted)',
|
||||||
|
transition: 'color var(--transition-fast)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.monitored ? <Bookmark size={14} fill="currentColor" /> : <BookmarkPlus size={14} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{item.status !== 'downloaded' && item.status !== 'downloading' && item.status !== 'queued' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDownload(item.id);
|
||||||
|
}}
|
||||||
|
title="Download"
|
||||||
|
aria-label={`Download ${item.title}`}
|
||||||
|
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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Film,
|
Film,
|
||||||
|
Grid3X3,
|
||||||
|
List,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
Loader,
|
Loader,
|
||||||
Music,
|
Music,
|
||||||
|
|
@ -29,6 +31,7 @@ import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { QualityLabel } from '../components/QualityLabel';
|
import { QualityLabel } from '../components/QualityLabel';
|
||||||
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||||
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
||||||
|
import { ContentCard } from '../components/ContentCard';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
||||||
|
|
@ -108,6 +111,10 @@ export function ChannelDetail() {
|
||||||
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
const [contentTypeFilter, setContentTypeFilter] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
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(() => ({
|
const contentFilters: ChannelContentFilters = useMemo(() => ({
|
||||||
page: contentPage,
|
page: contentPage,
|
||||||
|
|
@ -280,6 +287,14 @@ export function ChannelDetail() {
|
||||||
setContentPage(1);
|
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') => {
|
const togglePlaylist = useCallback((id: number | 'uncategorized') => {
|
||||||
setExpandedPlaylists((prev) => {
|
setExpandedPlaylists((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
|
|
@ -614,6 +629,37 @@ export function ChannelDetail() {
|
||||||
[contentColumns, sortKey, sortDirection, handleSort],
|
[contentColumns, sortKey, sortDirection, handleSort],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderCardGrid = useCallback(
|
||||||
|
(items: ContentItem[]) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
|
||||||
|
gap: 'var(--space-4)',
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div style={{ gridColumn: '1 / -1', padding: 'var(--space-8)', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
No content found for this channel.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<ContentCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
selected={selectedIds.has(item.id)}
|
||||||
|
onSelect={toggleSelect}
|
||||||
|
onToggleMonitored={(id, monitored) => toggleMonitored.mutate({ contentId: id, monitored })}
|
||||||
|
onDownload={(id) => downloadContent.mutate(id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
[selectedIds, toggleSelect, toggleMonitored, downloadContent],
|
||||||
|
);
|
||||||
|
|
||||||
const renderPlaylistGroups = useCallback(
|
const renderPlaylistGroups = useCallback(
|
||||||
(groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => (
|
(groups: { id: number | 'uncategorized'; title: string; items: ContentItem[] }[]) => (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -1117,6 +1163,27 @@ export function ChannelDetail() {
|
||||||
<option value="audio">Audio</option>
|
<option value="audio">Audio</option>
|
||||||
<option value="livestream">Livestream</option>
|
<option value="livestream">Livestream</option>
|
||||||
</select>
|
</select>
|
||||||
|
{/* View toggle */}
|
||||||
|
<button
|
||||||
|
onClick={handleViewToggle}
|
||||||
|
title={viewMode === 'table' ? 'Switch to card view' : 'Switch to table view'}
|
||||||
|
aria-label={viewMode === 'table' ? 'Switch to card view' : 'Switch to table view'}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
backgroundColor: 'var(--bg-input)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
transition: 'all var(--transition-fast)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{viewMode === 'table' ? <Grid3X3 size={16} /> : <List size={16} />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{contentError ? (
|
{contentError ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1163,6 +1230,8 @@ export function ChannelDetail() {
|
||||||
<SkeletonTable rows={8} columns={8} />
|
<SkeletonTable rows={8} columns={8} />
|
||||||
) : hasPlaylistGroups ? (
|
) : hasPlaylistGroups ? (
|
||||||
renderPlaylistGroups(playlistGroups!)
|
renderPlaylistGroups(playlistGroups!)
|
||||||
|
) : viewMode === 'card' ? (
|
||||||
|
renderCardGrid(content)
|
||||||
) : (
|
) : (
|
||||||
renderTable(content)
|
renderTable(content)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,12 @@ tbody tr:hover {
|
||||||
background-color: var(--bg-hover);
|
background-color: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Card checkbox visibility on hover ── */
|
||||||
|
div:hover > .card-checkbox,
|
||||||
|
.card-checkbox:has(input:checked) {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
:root {
|
:root {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue