feat: Created ContentListItem component with horizontal flexbox layout:…
- "src/frontend/src/components/ContentListItem.tsx" GSD-Task: S03/T01
This commit is contained in:
parent
ab7ab3634b
commit
592c8b1317
1 changed files with 295 additions and 0 deletions
295
src/frontend/src/components/ContentListItem.tsx
Normal file
295
src/frontend/src/components/ContentListItem.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3)',
|
||||
padding: 'var(--space-2)',
|
||||
backgroundColor: selected ? 'var(--bg-selected)' : 'var(--bg-card-solid)',
|
||||
border: selected ? '1px solid var(--accent)' : '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
transition: 'all var(--transition-fast)',
|
||||
cursor: 'pointer',
|
||||
minHeight: 56,
|
||||
}}
|
||||
onClick={() => 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 */}
|
||||
<div
|
||||
className="list-checkbox"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
opacity: selected ? 1 : 0,
|
||||
transition: 'opacity var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(item.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={`Select ${item.title}`}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
cursor: 'pointer',
|
||||
accentColor: 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
width: 100,
|
||||
aspectRatio: '16/9',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
overflow: 'hidden',
|
||||
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={20} /> : <Film size={20} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration badge on thumbnail */}
|
||||
{duration && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
padding: '0px 4px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
color: '#fff',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Download progress overlay */}
|
||||
{item.status === 'downloading' && progress && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}>
|
||||
<DownloadProgressBar progress={progress} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info section */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Title */}
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => 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}
|
||||
</a>
|
||||
|
||||
{/* Meta row: published · duration · content type */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
}}
|
||||
>
|
||||
{published && <span>{published}</span>}
|
||||
{published && duration && <span style={{ opacity: 0.5 }}>·</span>}
|
||||
{duration && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{duration}</span>}
|
||||
{(published || duration) && <span style={{ opacity: 0.5 }}>·</span>}
|
||||
<span style={{ textTransform: 'capitalize' }}>{item.contentType}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section: status badge + action buttons */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
}}
|
||||
>
|
||||
<StatusBadge status={item.status} />
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue