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:
parent
794181580f
commit
e1d5ef80b4
3 changed files with 409 additions and 76 deletions
257
src/frontend/src/components/ColumnConfig.tsx
Normal file
257
src/frontend/src/components/ColumnConfig.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react';
|
import { Bookmark, BookmarkPlus, Download, ExternalLink, Film, Music } from 'lucide-react';
|
||||||
import { StatusBadge } from './StatusBadge';
|
import { StatusBadge } from './StatusBadge';
|
||||||
|
import { RatingBadge } from './RatingBadge';
|
||||||
|
import { QualityLabel } from './QualityLabel';
|
||||||
import { DownloadProgressBar } from './DownloadProgressBar';
|
import { DownloadProgressBar } from './DownloadProgressBar';
|
||||||
import { useDownloadProgress } from '../contexts/DownloadProgressContext';
|
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 { ContentItem } from '@shared/types/index';
|
||||||
|
import type { ColumnConfig } from './ColumnConfig';
|
||||||
|
|
||||||
// ── Component ──
|
// ── Component ──
|
||||||
|
|
||||||
|
|
@ -13,12 +16,59 @@ interface ContentListItemProps {
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
onToggleMonitored: (id: number, monitored: boolean) => void;
|
onToggleMonitored: (id: number, monitored: boolean) => void;
|
||||||
onDownload: (id: number) => 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 progress = useDownloadProgress(item.id);
|
||||||
const duration = formatDuration(item.duration);
|
const duration = formatDuration(item.duration);
|
||||||
const published = formatRelativeTime(item.publishedAt);
|
|
||||||
|
const showThumbnail = isVisible(columnConfig, 'thumbnail');
|
||||||
|
const metaKeys = visibleKeys(columnConfig).filter((k) => k !== 'thumbnail');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -38,13 +88,11 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
onClick={() => onSelect(item.id)}
|
onClick={() => onSelect(item.id)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)';
|
if (!selected) e.currentTarget.style.borderColor = 'var(--border-light)';
|
||||||
// Reveal checkbox on hover
|
|
||||||
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
|
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
|
||||||
if (cb) cb.style.opacity = '1';
|
if (cb) cb.style.opacity = '1';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!selected) e.currentTarget.style.borderColor = 'var(--border)';
|
if (!selected) e.currentTarget.style.borderColor = 'var(--border)';
|
||||||
// Hide checkbox if not selected
|
|
||||||
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
|
const cb = e.currentTarget.querySelector('.list-checkbox') as HTMLElement | null;
|
||||||
if (cb && !selected) cb.style.opacity = '0';
|
if (cb && !selected) cb.style.opacity = '0';
|
||||||
}}
|
}}
|
||||||
|
|
@ -77,6 +125,7 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
|
{showThumbnail && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
@ -143,6 +192,7 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Info section */}
|
{/* Info section */}
|
||||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<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}
|
{item.title}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Meta row: published · duration · content type */}
|
{/* Meta row: dynamic columns */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -177,11 +227,16 @@ export function ContentListItem({ item, selected, onSelect, onToggleMonitored, o
|
||||||
fontSize: 'var(--font-size-xs)',
|
fontSize: 'var(--font-size-xs)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{published && <span>{published}</span>}
|
{metaKeys.reduce<React.ReactNode[]>((acc, key, i) => {
|
||||||
{published && duration && <span style={{ opacity: 0.5 }}>·</span>}
|
const node = renderMetaField(item, key);
|
||||||
{duration && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{duration}</span>}
|
if (node) {
|
||||||
{(published || duration) && <span style={{ opacity: 0.5 }}>·</span>}
|
if (acc.length > 0) {
|
||||||
<span style={{ textTransform: 'capitalize' }}>{item.contentType}</span>
|
acc.push(<MetaSeparator key={`sep-${key}`} />);
|
||||||
|
}
|
||||||
|
acc.push(<span key={key}>{node}</span>);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [])}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import { DownloadProgressBar } from '../components/DownloadProgressBar';
|
||||||
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
import { SkeletonChannelHeader, SkeletonTable } from '../components/Skeleton';
|
||||||
import { ContentCard } from '../components/ContentCard';
|
import { ContentCard } from '../components/ContentCard';
|
||||||
import { ContentListItem } from '../components/ContentListItem';
|
import { ContentListItem } from '../components/ContentListItem';
|
||||||
|
import { ColumnConfigPopover, DEFAULT_COLUMN_CONFIG, mergeColumnConfig, type ColumnConfig } from '../components/ColumnConfig';
|
||||||
import { Pagination } from '../components/Pagination';
|
import { Pagination } from '../components/Pagination';
|
||||||
import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
import { SortGroupBar, type GroupByKey } from '../components/SortGroupBar';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
|
|
@ -110,8 +111,21 @@ export function ChannelDetail() {
|
||||||
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
|
const [sortDirection, setSortDirection] = usePersistedState<'asc' | 'desc'>('tubearr-sort-dir', 'asc');
|
||||||
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
|
const [groupBy, setGroupBy] = usePersistedState<GroupByKey>('tubearr-group-by', 'none');
|
||||||
const [viewMode, setViewMode] = usePersistedState<'table' | 'card' | 'list'>('tubearr-content-view', 'table');
|
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);
|
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
|
// Derive contentType filter from active tab
|
||||||
const contentTypeFilter = activeTab === 'all' ? '' : activeTab;
|
const contentTypeFilter = activeTab === 'all' ? '' : activeTab;
|
||||||
|
|
||||||
|
|
@ -797,12 +811,13 @@ export function ChannelDetail() {
|
||||||
onSelect={toggleSelect}
|
onSelect={toggleSelect}
|
||||||
onToggleMonitored={(id, monitored) => toggleMonitored.mutate({ contentId: id, monitored })}
|
onToggleMonitored={(id, monitored) => toggleMonitored.mutate({ contentId: id, monitored })}
|
||||||
onDownload={(id) => downloadContent.mutate(id)}
|
onDownload={(id) => downloadContent.mutate(id)}
|
||||||
|
columnConfig={mergedColumnConfig}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
[selectedIds, toggleSelect, toggleMonitored, downloadContent],
|
[selectedIds, toggleSelect, toggleMonitored, downloadContent, mergedColumnConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderGroupedContent = useCallback(
|
const renderGroupedContent = useCallback(
|
||||||
|
|
@ -1811,6 +1826,12 @@ export function ChannelDetail() {
|
||||||
<LayoutList size={16} />
|
<LayoutList size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<ColumnConfigPopover
|
||||||
|
columns={mergedColumnConfig}
|
||||||
|
onChange={setListColumnConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Sort & Group controls */}
|
{/* Sort & Group controls */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue