chore: Added column config popover with visibility toggles and drag reo…

- "src/frontend/src/components/ColumnConfig.tsx"
- "src/frontend/src/components/ContentListItem.tsx"
- "src/frontend/src/pages/ChannelDetail.tsx"

GSD-Task: S07/T01
This commit is contained in:
jlightner 2026-04-04 10:22:47 +00:00
parent 794181580f
commit e1d5ef80b4
3 changed files with 409 additions and 76 deletions

View file

@ -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<string>();
// 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<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [dragIndex, setDragIndex] = useState<number | null>(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 (
<div style={{ position: 'relative' }}>
<button
ref={buttonRef}
onClick={() => setOpen((prev) => !prev)}
title="Configure columns"
aria-label="Configure list columns"
aria-expanded={open}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 34,
height: 34,
borderRadius: 'var(--radius-md)',
backgroundColor: open ? 'var(--accent)' : 'var(--bg-input)',
color: open ? '#fff' : 'var(--text-secondary)',
border: '1px solid var(--border-light)',
cursor: 'pointer',
transition: 'all var(--transition-fast)',
flexShrink: 0,
}}
>
<Settings2 size={16} />
</button>
{open && (
<div
ref={popoverRef}
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
right: 0,
zIndex: 200,
minWidth: 220,
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-md)',
padding: 'var(--space-2) 0',
}}
>
<div
style={{
padding: 'var(--space-2) var(--space-3)',
borderBottom: '1px solid var(--border)',
marginBottom: 'var(--space-1)',
}}
>
<span
style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 600,
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}
>
Columns ({visibleCount}/{columns.length})
</span>
</div>
{columns.map((col, idx) => {
const def = LIST_COLUMNS.find((c) => c.key === col.key);
if (!def) return null;
return (
<div
key={col.key}
draggable
onDragStart={() => 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',
}}
>
<GripVertical
size={14}
style={{ color: 'var(--text-muted)', flexShrink: 0, opacity: 0.5 }}
/>
<button
onClick={() => toggleVisibility(col.key)}
title={col.visible ? `Hide ${def.label}` : `Show ${def.label}`}
aria-label={col.visible ? `Hide ${def.label} column` : `Show ${def.label} column`}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
borderRadius: 'var(--radius-sm)',
color: col.visible ? 'var(--accent)' : 'var(--text-muted)',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
transition: 'color var(--transition-fast)',
flexShrink: 0,
padding: 0,
}}
>
{col.visible ? <Eye size={14} /> : <EyeOff size={14} />}
</button>
<span
style={{
fontSize: 'var(--font-size-sm)',
color: col.visible ? 'var(--text-primary)' : 'var(--text-muted)',
flex: 1,
}}
>
{def.label}
</span>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -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 <span style={{ opacity: 0.5 }}>·</span>;
}
function renderMetaField(item: ContentItem, key: string): React.ReactNode {
switch (key) {
case 'contentType':
return <span style={{ textTransform: 'capitalize' }}>{item.contentType}</span>;
case 'contentRating':
return item.contentRating ? <RatingBadge rating={item.contentRating} /> : null;
case 'quality':
return item.qualityMetadata ? <QualityLabel quality={item.qualityMetadata} /> : null;
case 'publishedAt':
return formatRelativeTime(item.publishedAt) ? <span>{formatRelativeTime(item.publishedAt)}</span> : null;
case 'downloadedAt':
return formatRelativeTime(item.downloadedAt) ? <span>{formatRelativeTime(item.downloadedAt)}</span> : null;
case 'duration': {
const d = formatDuration(item.duration);
return d ? <span style={{ fontVariantNumeric: 'tabular-nums' }}>{d}</span> : null;
}
case 'fileSize': {
const s = formatFileSize(item.fileSize);
return s ? <span>{s}</span> : 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 (
<div
@ -38,13 +88,11 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
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';
}}
@ -77,72 +125,74 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
</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>
)}
{showThumbnail && (
<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>
)}
{/* 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>
{/* 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 }}>
@ -167,7 +217,7 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
{item.title}
</a>
{/* Meta row: published · duration · content type */}
{/* Meta row: dynamic columns */}
<div
style={{
display: 'flex',
@ -177,11 +227,16 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
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>
{metaKeys.reduce<React.ReactNode[]>((acc, key, i) => {
const node = renderMetaField(item, key);
if (node) {
if (acc.length > 0) {
acc.push(<MetaSeparator key={`sep-${key}`} />);
}
acc.push(<span key={key}>{node}</span>);
}
return acc;
}, [])}
</div>
</div>

View file

@ -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<GroupByKey>('tubearr-group-by', 'none');
const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table');
const [listColumnConfig, setListColumnConfig] = usePersistedState<ColumnConfig[]>(
'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}
/>
))
)}
</div>
),
[selectedIds, toggleSelect, toggleMonitored, downloadContent],
[selectedIds, toggleSelect, toggleMonitored, downloadContent, mergedColumnConfig],
);
const renderGroupedContent = useCallback(
@ -1811,6 +1826,12 @@ export function ChannelDetail() {
<LayoutList size={16} />
</button>
</div>
{viewMode === 'list' && (
<ColumnConfigPopover
columns={mergedColumnConfig}
onChange={setListColumnConfig}
/>
)}
</div>
</div>
{/* Sort & Group controls */}