Files
youtube-studio-flow/frontend/src/components/shared/SavedViewManager.tsx
T
devil b51c79e889 fix(frontend): resolve build-blocking lint and type errors
next build runs ESLint + full type-check and fails on errors (not just
warnings) - the production build was broken. Fixes: unused imports/vars,
any-typed diff/preference lookups replaced with the actual union/Record
types, ternary-as-statement flagged by no-unused-expressions, unescaped
JSX entities, a UserPreferences interface missing two fields the video
editor already reads/writes at runtime, and an import of
ColumnVisibilityState which @tanstack/react-table does not export
(the real name is VisibilityState).
2026-08-11 14:18:26 +02:00

172 lines
7.3 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Plus, Pin, PinOff, Trash2, ChevronUp, ChevronDown, Loader2 } from 'lucide-react';
import {
fetchSavedViews, createSavedView, updateSavedView, deleteSavedView,
type SavedView, type VideosQuery,
} from '@/lib/api';
import styles from './SavedViewManager.module.css';
interface Props {
onClose: () => void;
currentFilters: Partial<VideosQuery>;
currentColumnsJson: Record<string, unknown>;
currentSortJson: Record<string, unknown> | null;
}
export default function SavedViewManager({ onClose, currentFilters, currentColumnsJson, currentSortJson }: Props) {
const qc = useQueryClient();
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [pinNew, setPinNew] = useState(false);
const [creating, setCreating] = useState(false);
const { data: views = [], isLoading } = useQuery({
queryKey: ['savedViews'],
queryFn: fetchSavedViews,
staleTime: 30_000,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['savedViews'] });
qc.invalidateQueries({ queryKey: ['savedViewTabs'] });
};
const createMut = useMutation({
mutationFn: () => createSavedView({
name: newName.trim(),
description: newDesc.trim() || undefined,
queryJson: currentFilters as Record<string, unknown>,
columnsJson: currentColumnsJson,
sortJson: currentSortJson,
pinnedAsTab: pinNew,
tabOrder: pinNew ? (views.filter((v) => v.pinnedAsTab).length) : undefined,
}),
onSuccess: () => { invalidate(); setNewName(''); setNewDesc(''); setPinNew(false); setCreating(false); },
});
const patchMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateSavedView>[1] }) =>
updateSavedView(id, data),
onSuccess: invalidate,
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteSavedView(id),
onSuccess: invalidate,
});
const pinnedViews = views.filter((v) => v.pinnedAsTab).sort((a, b) => (a.tabOrder ?? 99) - (b.tabOrder ?? 99));
const unpinnedViews = views.filter((v) => !v.pinnedAsTab).sort((a, b) => a.name.localeCompare(b.name));
const moveTab = (view: SavedView, dir: -1 | 1) => {
const idx = pinnedViews.findIndex((v) => v.id === view.id);
const swapIdx = idx + dir;
if (swapIdx < 0 || swapIdx >= pinnedViews.length) return;
const swap = pinnedViews[swapIdx];
Promise.all([
patchMut.mutateAsync({ id: view.id, data: { tabOrder: swapIdx } }),
patchMut.mutateAsync({ id: swap.id, data: { tabOrder: idx } }),
]);
};
return (
<div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>Saved Views</h2>
<button className={styles.closeBtn} onClick={onClose}><X size={18} /></button>
</div>
<div className={styles.body}>
{/* Create new view */}
{creating ? (
<div className={styles.createForm}>
<input
className={styles.nameInput}
placeholder="View name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
autoFocus
/>
<input
className={styles.descInput}
placeholder="Description (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
<label className={styles.pinLabel}>
<input type="checkbox" checked={pinNew} onChange={(e) => setPinNew(e.target.checked)} />
Pin as tab
</label>
<div className={styles.createActions}>
<button className="btn btn-secondary" onClick={() => setCreating(false)}>Cancel</button>
<button
className="btn btn-primary"
disabled={!newName.trim() || createMut.isPending}
onClick={() => createMut.mutate()}
>
{createMut.isPending ? <Loader2 size={14} className={styles.spin} /> : null}
Save view
</button>
</div>
</div>
) : (
<button className={styles.newViewBtn} onClick={() => setCreating(true)}>
<Plus size={14} /> Save current filters as view
</button>
)}
{isLoading && <div className={styles.loading}><Loader2 size={18} className={styles.spin} /> Loading</div>}
{/* Pinned as tabs */}
{pinnedViews.length > 0 && (
<section>
<div className={styles.sectionTitle}>Pinned Tabs</div>
{pinnedViews.map((view, idx) => (
<div key={view.id} className={styles.viewRow}>
<div className={styles.viewMeta}>
<span className={styles.viewName}>{view.name}</span>
{view.description && <span className={styles.viewDesc}>{view.description}</span>}
</div>
<div className={styles.viewActions}>
<button title="Move up" className={styles.iconBtn} disabled={idx === 0} onClick={() => moveTab(view, -1)}><ChevronUp size={14} /></button>
<button title="Move down" className={styles.iconBtn} disabled={idx === pinnedViews.length - 1} onClick={() => moveTab(view, 1)}><ChevronDown size={14} /></button>
<button title="Unpin" className={styles.iconBtn} onClick={() => patchMut.mutate({ id: view.id, data: { pinnedAsTab: false, tabOrder: null } })}><PinOff size={14} /></button>
<button title="Delete" className={`${styles.iconBtn} ${styles.iconBtnDanger}`} onClick={() => { if (confirm(`Delete "${view.name}"?`)) deleteMut.mutate(view.id); }}><Trash2 size={14} /></button>
</div>
</div>
))}
</section>
)}
{/* All other views */}
{unpinnedViews.length > 0 && (
<section>
<div className={styles.sectionTitle}>All Views</div>
{unpinnedViews.map((view) => (
<div key={view.id} className={styles.viewRow}>
<div className={styles.viewMeta}>
<span className={styles.viewName}>{view.name}</span>
{view.description && <span className={styles.viewDesc}>{view.description}</span>}
</div>
<div className={styles.viewActions}>
<button title="Pin as tab" className={styles.iconBtn} onClick={() => patchMut.mutate({ id: view.id, data: { pinnedAsTab: true, tabOrder: pinnedViews.length } })}><Pin size={14} /></button>
<button title="Delete" className={`${styles.iconBtn} ${styles.iconBtnDanger}`} onClick={() => { if (confirm(`Delete "${view.name}"?`)) deleteMut.mutate(view.id); }}><Trash2 size={14} /></button>
</div>
</div>
))}
</section>
)}
{!isLoading && views.length === 0 && !creating && (
<p className={styles.empty}>No saved views yet. Save your current filters to create one.</p>
)}
</div>
</div>
</div>
);
}