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 { 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue