Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
@@ -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>
);
}