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).
172 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|