diff --git a/src/frontend/src/components/ColumnConfig.tsx b/src/frontend/src/components/ColumnConfig.tsx new file mode 100644 index 0000000..13f3b9a --- /dev/null +++ b/src/frontend/src/components/ColumnConfig.tsx @@ -0,0 +1,257 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Settings2, GripVertical, Eye, EyeOff } from 'lucide-react'; + +// ── Types ── + +export interface ListColumnDef { + key: string; + label: string; + /** Columns that cannot be hidden */ + required?: boolean; +} + +export interface ColumnConfig { + key: string; + visible: boolean; +} + +// ── Constants ── + +export const LIST_COLUMNS: ListColumnDef[] = [ + { key: 'thumbnail', label: 'Thumbnail' }, + { key: 'contentType', label: 'Type' }, + { key: 'contentRating', label: 'Rating' }, + { key: 'quality', label: 'Quality' }, + { key: 'publishedAt', label: 'Published' }, + { key: 'downloadedAt', label: 'Downloaded' }, + { key: 'duration', label: 'Duration' }, + { key: 'fileSize', label: 'Size' }, +]; + +export const DEFAULT_COLUMN_CONFIG: ColumnConfig[] = LIST_COLUMNS.map((col) => ({ + key: col.key, + visible: true, +})); + +/** Merge stored config with current column defs — handles new columns added after storage was saved */ +export function mergeColumnConfig(stored: ColumnConfig[]): ColumnConfig[] { + const storedMap = new Map(stored.map((c) => [c.key, c])); + const merged: ColumnConfig[] = []; + const seen = new Set(); + + // Preserve stored order for known columns + for (const s of stored) { + if (LIST_COLUMNS.some((c) => c.key === s.key)) { + merged.push(s); + seen.add(s.key); + } + } + + // Append any new columns not in stored config + for (const col of LIST_COLUMNS) { + if (!seen.has(col.key)) { + merged.push({ key: col.key, visible: true }); + } + } + + return merged; +} + +// ── Component ── + +interface ColumnConfigPopoverProps { + columns: ColumnConfig[]; + onChange: (columns: ColumnConfig[]) => void; +} + +export function ColumnConfigPopover({ columns, onChange }: ColumnConfigPopoverProps) { + const [open, setOpen] = useState(false); + const popoverRef = useRef(null); + const buttonRef = useRef(null); + const [dragIndex, setDragIndex] = useState(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handleClick = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + buttonRef.current && + !buttonRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + const toggleVisibility = useCallback( + (key: string) => { + onChange( + columns.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c)), + ); + }, + [columns, onChange], + ); + + const moveColumn = useCallback( + (fromIdx: number, toIdx: number) => { + if (fromIdx === toIdx) return; + const next = [...columns]; + const [moved] = next.splice(fromIdx, 1); + next.splice(toIdx, 0, moved); + onChange(next); + }, + [columns, onChange], + ); + + const handleDragStart = useCallback((idx: number) => { + setDragIndex(idx); + }, []); + + const handleDragOver = useCallback( + (e: React.DragEvent, idx: number) => { + e.preventDefault(); + if (dragIndex !== null && dragIndex !== idx) { + moveColumn(dragIndex, idx); + setDragIndex(idx); + } + }, + [dragIndex, moveColumn], + ); + + const handleDragEnd = useCallback(() => { + setDragIndex(null); + }, []); + + const visibleCount = columns.filter((c) => c.visible).length; + + return ( +
+ + + {open && ( +
+
+ + Columns ({visibleCount}/{columns.length}) + +
+ + {columns.map((col, idx) => { + const def = LIST_COLUMNS.find((c) => c.key === col.key); + if (!def) return null; + + return ( +
handleDragStart(idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDragEnd={handleDragEnd} + style={{ + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2)', + padding: 'var(--space-1) var(--space-3)', + cursor: 'grab', + backgroundColor: dragIndex === idx ? 'var(--bg-hover)' : 'transparent', + transition: 'background-color var(--transition-fast)', + userSelect: 'none', + }} + > + + + + {def.label} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/frontend/src/components/ContentListItem.tsx b/src/frontend/src/components/ContentListItem.tsx index 1eec403..c72feb5 100644 --- a/src/frontend/src/components/ContentListItem.tsx +++ b/src/frontend/src/components/ContentListItem.tsx @@ -1,9 +1,12 @@ import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react'; import { StatusBadge } from './StatusBadge'; +import { RatingBadge } from './RatingBadge'; +import { QualityLabel } from './QualityLabel'; import { DownloadProgressBar } from './DownloadProgressBar'; import { useDownloadProgress } from '../contexts/DownloadProgressContext'; -import { formatDuration, formatRelativeTime } from '../utils/format'; +import { formatDuration, formatRelativeTime, formatFileSize } from '../utils/format'; import type { ContentItem } from '@shared/types/index'; +import type { ColumnConfig } from './ColumnConfig'; // ── Component ── @@ -13,12 +16,59 @@ interface ContentListItemProps { onSelect: (id: number) => void; onToggleMonitored: (id: number, monitored: boolean) => void; onDownload: (id: number) => void; + /** Optional column visibility/order config. If omitted, uses legacy hardcoded layout. */ + columnConfig?: ColumnConfig[]; } -export function ContentListItem({ item, selected, onSelect, onToggleMonitored, onDownload }: ContentListItemProps) { +/** Check if a column key is visible given the config */ +function isVisible(config: ColumnConfig[] | undefined, key: string): boolean { + if (!config) return true; // legacy: show all default fields + return config.some((c) => c.key === key && c.visible); +} + +/** Get ordered visible column keys */ +function visibleKeys(config: ColumnConfig[] | undefined): string[] { + if (!config) return ['thumbnail', 'publishedAt', 'duration', 'contentType']; + return config.filter((c) => c.visible).map((c) => c.key); +} + +// ── Meta field renderers ── + +function MetaSeparator() { + return ·; +} + +function renderMetaField(item: ContentItem, key: string): React.ReactNode { + switch (key) { + case 'contentType': + return {item.contentType}; + case 'contentRating': + return item.contentRating ? : null; + case 'quality': + return item.qualityMetadata ? : null; + case 'publishedAt': + return formatRelativeTime(item.publishedAt) ? {formatRelativeTime(item.publishedAt)} : null; + case 'downloadedAt': + return formatRelativeTime(item.downloadedAt) ? {formatRelativeTime(item.downloadedAt)} : null; + case 'duration': { + const d = formatDuration(item.duration); + return d ? {d} : null; + } + case 'fileSize': { + const s = formatFileSize(item.fileSize); + return s ? {s} : null; + } + default: + return null; + } +} + +export function ContentListItem({ item, selected, onSelect, onToggleMonitored, onDownload, columnConfig }: ContentListItemProps) { const progress = useDownloadProgress(item.id); const duration = formatDuration(item.duration); - const published = formatRelativeTime(item.publishedAt); + + const showThumbnail = isVisible(columnConfig, 'thumbnail'); + const metaKeys = visibleKeys(columnConfig).filter((k) => k !== 'thumbnail'); 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'; }} @@ -77,72 +125,74 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
{/* Thumbnail */} -
- {item.thumbnailUrl ? ( - - ) : ( -
- {item.contentType === 'audio' ? : } -
- )} + {showThumbnail && ( +
+ {item.thumbnailUrl ? ( + + ) : ( +
+ {item.contentType === 'audio' ? : } +
+ )} - {/* Duration badge on thumbnail */} - {duration && ( - - {duration} - - )} + {/* Duration badge on thumbnail */} + {duration && ( + + {duration} + + )} - {/* Download progress overlay */} - {item.status === 'downloading' && progress && ( -
- -
- )} -
+ {/* Download progress overlay */} + {item.status === 'downloading' && progress && ( +
+ +
+ )} +
+ )} {/* Info section */}
@@ -167,7 +217,7 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o {item.title} - {/* Meta row: published · duration · content type */} + {/* Meta row: dynamic columns */}
- {published && {published}} - {published && duration && ·} - {duration && {duration}} - {(published || duration) && ·} - {item.contentType} + {metaKeys.reduce((acc, key, i) => { + const node = renderMetaField(item, key); + if (node) { + if (acc.length > 0) { + acc.push(); + } + acc.push({node}); + } + return acc; + }, [])}
diff --git a/src/frontend/src/pages/ChannelDetail.tsx b/src/frontend/src/pages/ChannelDetail.tsx index d8d36da..a6c1053 100644 --- a/src/frontend/src/pages/ChannelDetail.tsx +++ b/src/frontend/src/pages/ChannelDetail.tsx @@ -40,6 +40,7 @@ import { DownloadProgressBar } from '../components/DownloadProgressBar'; import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton'; import { ContentCard } from '../components/ContentCard'; import { ContentListItem } from '../components/ContentListItem'; +import { ColumnConfigPopover, DEFAULT_COLUMN_CONFIG, mergeColumnConfig, type ColumnConfig } from '../components/ColumnConfig'; import { Pagination } from '../components/Pagination'; import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar'; import { Modal } from '../components/Modal'; @@ -110,8 +111,21 @@ export function ChannelDetail() { const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc'); const [groupBy, setGroupBy] = usePersistedState('tubearr-group-by', 'none'); const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table'); + const [listColumnConfig, setListColumnConfig] = usePersistedState( + 'tubearr-list-columns', + DEFAULT_COLUMN_CONFIG, + ); const viewModeOverriddenRef = useRef(false); + // Merge stored column config with current column defs (handles new columns) + const mergedColumnConfig = mergeColumnConfig(listColumnConfig); + // Sync merged config back if it differs (new columns were added) + useEffect(() => { + if (mergedColumnConfig.length !== listColumnConfig.length) { + setListColumnConfig(mergedColumnConfig); + } + }, [mergedColumnConfig.length]); // eslint-disable-line react-hooks/exhaustive-deps + // Derive contentType filter from active tab const contentTypeFilter = activeTab === 'all' ? '' : activeTab; @@ -797,12 +811,13 @@ export function ChannelDetail() { onSelect={toggleSelect} onToggleMonitored={(id, monitored) => toggleMonitored.mutate({ contentId: id, monitored })} onDownload={(id) => downloadContent.mutate(id)} + columnConfig={mergedColumnConfig} /> )) )} ), - [selectedIds, toggleSelect, toggleMonitored, downloadContent], + [selectedIds, toggleSelect, toggleMonitored, downloadContent, mergedColumnConfig], ); const renderGroupedContent = useCallback( @@ -1811,6 +1826,12 @@ export function ChannelDetail() { + {viewMode === 'list' && ( + + )} {/* Sort & Group controls */}