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).
246 lines
8.6 KiB
TypeScript
246 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import { useRouter } from 'next/navigation';
|
|
import { Loader2, X, ChevronDown, ChevronRight } from 'lucide-react';
|
|
import {
|
|
fetchPushPendingPreview, confirmBulkPush,
|
|
type PushPendingPreviewItem,
|
|
} from '@/lib/api';
|
|
import styles from './PushPendingModal.module.css';
|
|
|
|
const FIELD_LABELS: Record<string, string> = {
|
|
title: 'Title', description: 'Description', privacyStatus: 'Visibility',
|
|
tags: 'Tags', categoryId: 'Category', defaultLanguage: 'Language',
|
|
defaultAudioLanguage: 'Audio Language', selfDeclaredMadeForKids: 'Made for Kids',
|
|
embeddable: 'Embeddable', license: 'License', recordingDate: 'Recording Date',
|
|
};
|
|
|
|
function FieldChip({ field }: { field: string }) {
|
|
return <span className={styles.fieldChip}>{FIELD_LABELS[field] ?? field}</span>;
|
|
}
|
|
|
|
function TagsDiff({ before, after }: { before: string[]; after: string[] }) {
|
|
const beforeSet = new Set(before);
|
|
const afterSet = new Set(after);
|
|
const removed = before.filter((t) => !afterSet.has(t));
|
|
const added = after.filter((t) => !beforeSet.has(t));
|
|
const kept = after.filter((t) => beforeSet.has(t));
|
|
return (
|
|
<div className={styles.tagsDiff}>
|
|
{kept.map((t) => <span key={t} className={styles.tagKept}>{t}</span>)}
|
|
{removed.map((t) => <span key={t} className={styles.tagRemoved}>{t}</span>)}
|
|
{added.map((t) => <span key={t} className={styles.tagAdded}>{t}</span>)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem }) {
|
|
const label = FIELD_LABELS[field] ?? field;
|
|
const d = item.diff[field as keyof typeof item.diff];
|
|
if (!d) return null;
|
|
|
|
if (field === 'tags') {
|
|
return (
|
|
<tr>
|
|
<td className={styles.diffField}>{label}</td>
|
|
<td className={styles.diffBefore}>{(d.before as string[]).join(', ') || '—'}</td>
|
|
<td className={styles.diffAfter}><TagsDiff before={d.before as string[]} after={d.after as string[]} /></td>
|
|
</tr>
|
|
);
|
|
}
|
|
if (field === 'description') {
|
|
return (
|
|
<tr>
|
|
<td className={styles.diffField}>{label}</td>
|
|
<td className={styles.diffBefore}><pre className={styles.descPre}>{d.before ?? '—'}</pre></td>
|
|
<td className={styles.diffAfter}><pre className={styles.descPre}>{d.after ?? '—'}</pre></td>
|
|
</tr>
|
|
);
|
|
}
|
|
const fmt = (v: string | number | boolean | string[] | null | undefined) => {
|
|
if (v === null || v === undefined) return '—';
|
|
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
|
|
return String(v);
|
|
};
|
|
return (
|
|
<tr>
|
|
<td className={styles.diffField}>{label}</td>
|
|
<td className={styles.diffBefore}>{fmt(d.before)}</td>
|
|
<td className={styles.diffAfter}>{fmt(d.after)}</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function VideoRow({
|
|
item,
|
|
selected,
|
|
onToggle,
|
|
}: {
|
|
item: PushPendingPreviewItem;
|
|
selected: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const thumb = item.thumbnailUrl ?? `https://img.youtube.com/vi/${item.videoId}/mqdefault.jpg`;
|
|
|
|
return (
|
|
<>
|
|
<tr className={`${styles.row} ${!selected ? styles.rowDeselected : ''}`}>
|
|
<td className={styles.colCheck}>
|
|
<input type="checkbox" checked={selected} onChange={onToggle} className={styles.checkbox} />
|
|
</td>
|
|
<td className={styles.colThumb}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={thumb} alt="" width={80} height={45} className={styles.thumb} />
|
|
</td>
|
|
<td className={styles.colMeta}>
|
|
<span className={styles.rowTitle}>{item.title}</span>
|
|
<div className={styles.chips}>
|
|
{item.firstSync
|
|
? <span className={`${styles.fieldChip} ${styles.chipFirstSync}`}>First sync</span>
|
|
: item.changedFields.map((f) => <FieldChip key={f} field={f} />)
|
|
}
|
|
</div>
|
|
</td>
|
|
<td className={styles.colExpand}>
|
|
<button className={styles.expandBtn} onClick={() => setExpanded((e) => !e)}>
|
|
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{expanded && (
|
|
<tr className={styles.diffRow}>
|
|
<td colSpan={4} className={styles.diffCell}>
|
|
<table className={styles.diffTable}>
|
|
<thead>
|
|
<tr>
|
|
<th className={styles.diffField}>Field</th>
|
|
<th className={styles.diffBefore}>Before</th>
|
|
<th className={styles.diffAfter}>After</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{item.changedFields.map((f) => (
|
|
<DiffRow key={f} field={f} item={item} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function PushPendingModal({ onClose, sort, order }: { onClose: () => void; sort?: string; order?: string }) {
|
|
const router = useRouter();
|
|
|
|
const { data: items = [], isLoading } = useQuery({
|
|
queryKey: ['pushPendingPreview', sort, order],
|
|
queryFn: () => fetchPushPendingPreview(sort, order),
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
// null = untouched (treat as all selected); Set = explicit selection state
|
|
const [selected, setSelected] = useState<Set<string> | null>(null);
|
|
|
|
const effectiveSelected = useMemo(
|
|
() => selected ?? new Set(items.map((i) => i.videoId)),
|
|
[items, selected],
|
|
);
|
|
|
|
const allSelected = items.length > 0 && effectiveSelected.size === items.length;
|
|
|
|
const toggleAll = () => {
|
|
if (allSelected) setSelected(new Set());
|
|
else setSelected(new Set(items.map((i) => i.videoId)));
|
|
};
|
|
|
|
const toggleOne = (id: string) => {
|
|
const next = new Set(effectiveSelected);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
setSelected(next);
|
|
};
|
|
|
|
const confirmMut = useMutation({
|
|
mutationFn: () => confirmBulkPush([...effectiveSelected]),
|
|
onSuccess: () => {
|
|
setSelected(null);
|
|
onClose();
|
|
router.push('/bulk-jobs');
|
|
},
|
|
});
|
|
|
|
const selectedCount = effectiveSelected.size;
|
|
|
|
return (
|
|
<div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
<div className={styles.modal}>
|
|
<div className={styles.modalHeader}>
|
|
<h2 className={styles.modalTitle}>Push Pending Videos</h2>
|
|
<button className={styles.closeBtn} onClick={onClose}><X size={18} /></button>
|
|
</div>
|
|
|
|
<div className={styles.tableContainer}>
|
|
{isLoading && (
|
|
<div className={styles.loadingState}>
|
|
<Loader2 size={20} className={styles.spinner} />
|
|
<span>Loading pending changes…</span>
|
|
</div>
|
|
)}
|
|
{!isLoading && items.length === 0 && (
|
|
<div className={styles.emptyState}>No pending videos found.</div>
|
|
)}
|
|
{!isLoading && items.length > 0 && (
|
|
<table className={styles.videoTable}>
|
|
<thead>
|
|
<tr>
|
|
<th className={styles.colCheck}></th>
|
|
<th className={styles.colThumb}></th>
|
|
<th className={styles.colMeta}>Video</th>
|
|
<th className={styles.colExpand}></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item) => (
|
|
<VideoRow
|
|
key={item.videoId}
|
|
item={item}
|
|
selected={effectiveSelected.has(item.videoId)}
|
|
onToggle={() => toggleOne(item.videoId)}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{!isLoading && items.length > 0 && (
|
|
<div className={styles.modalFooter}>
|
|
<button className={styles.selectAll} onClick={toggleAll}>
|
|
{allSelected ? 'Deselect all' : 'Select all'}
|
|
</button>
|
|
<span className={styles.footerNote}>
|
|
Videos are pushed in order, respecting YouTube quota limits.
|
|
</span>
|
|
<div className={styles.footerActions}>
|
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
disabled={selectedCount === 0 || confirmMut.isPending}
|
|
onClick={() => confirmMut.mutate()}
|
|
>
|
|
{confirmMut.isPending ? <Loader2 size={14} className={styles.spinner} /> : null}
|
|
Push {selectedCount} video{selectedCount !== 1 ? 's' : ''}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|