Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
'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 diff = item.diff as any;
|
||||
const d = diff[field];
|
||||
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} after={d.after} /></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: any) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user