diff --git a/src/frontend/src/components/ContentListItem.tsx b/src/frontend/src/components/ContentListItem.tsx new file mode 100644 index 0000000..e99e64d --- /dev/null +++ b/src/frontend/src/components/ContentListItem.tsx @@ -0,0 +1,295 @@ +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 (shared pattern with ContentCard) ── + +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 ContentListItemProps { + item: ContentItem; + selected: boolean; + onSelect: (id: number) => void; + onToggleMonitored: (id: number, monitored: boolean) => void; + onDownload: (id: number) => void; +} + +export function ContentListItem({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentListItemProps) { + 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)'; + // Reveal checkbox on hover + const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null; + if (cb) cb.style.opacity = '1'; + }} + onMouseLeave={(e) => { + if (!selected) e.currentTarget.style.borderColor = 'var(--border)'; + // Hide checkbox if not selected + const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null; + if (cb && !selected) cb.style.opacity = '0'; + }} + > + {/* Selection checkbox */} +
+ { + e.stopPropagation(); + onSelect(item.id); + }} + onClick={(e) => e.stopPropagation()} + aria-label={`Select ${item.title}`} + style={{ + width: 16, + height: 16, + cursor: 'pointer', + accentColor: 'var(--accent)', + }} + /> +
+ + {/* Thumbnail */} +
+ {item.thumbnailUrl ? ( + + ) : ( +
+ {item.contentType === 'audio' ? : } +
+ )} + + {/* Duration badge on thumbnail */} + {duration && ( + + {duration} + + )} + + {/* Download progress overlay */} + {item.status === 'downloading' && progress && ( +
+ +
+ )} +
+ + {/* Info section */} +
+ {/* Title */} + e.stopPropagation()} + style={{ + fontWeight: 500, + fontSize: 'var(--font-size-sm)', + color: 'var(--text-primary)', + lineHeight: 1.3, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + textDecoration: 'none', + }} + title={item.title} + > + {item.title} + + + {/* Meta row: published · duration · content type */} +
+ {published && {published}} + {published && duration && ·} + {duration && {duration}} + {(published || duration) && ·} + {item.contentType} +
+
+ + {/* Right section: status badge + action buttons */} +
+ + +
+ + + {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)', + }} + > + + +
+
+
+ ); +}