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
+382
View File
@@ -0,0 +1,382 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Menu, Search, Sun, Moon, Eye, LogOut, Loader2, CloudUpload, CheckCircle2, XCircle, Clock, Loader } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useUIStore } from '@/store/useUIStore';
import { useAuthStore } from '@/store/useAuthStore';
import { useTheme } from '@/hooks/useTheme';
import apiClient from '@/lib/api-client';
import { fetchSavedViews, fetchTemplates, bulkPreview, bulkApply, fetchSyncQueueStatus, type BulkActionType, type BulkPreviewResponse } from '@/lib/api';
import Modal from './Modal';
import f from './FormField.module.css';
import styles from './Header.module.css';
// ─── Bulk Action Modal ────────────────────────────────────────────────────────
const ACTION_LABELS: Record<BulkActionType, string> = {
SET_PRIVACY: 'Set Privacy Status',
SET_TEMPLATE: 'Assign Template',
ADD_TAGS: 'Add Tags',
REMOVE_TAGS: 'Remove Tags',
SEARCH_REPLACE_TITLE: 'Search & Replace in Title',
};
function BulkModal({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState<'configure' | 'preview'>('configure');
const [actionType, setActionType] = useState<BulkActionType>('SET_PRIVACY');
const [targetType, setTargetType] = useState<'all' | 'view'>('all');
const [savedViewId, setSavedViewId] = useState('');
const [payload, setPayload] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<BulkPreviewResponse | null>(null);
const { data: savedViews = [] } = useQuery({ queryKey: ['saved-views'], queryFn: fetchSavedViews });
const { data: templates = [] } = useQuery({ queryKey: ['templates'], queryFn: fetchTemplates });
const buildPayload = () => {
if ((actionType === 'ADD_TAGS' || actionType === 'REMOVE_TAGS') && typeof payload.tags === 'string') {
return { ...payload, tags: payload.tags.split(',').map((t) => t.trim()).filter(Boolean) };
}
return payload;
};
const previewMut = useMutation({
mutationFn: () => bulkPreview({
type: actionType,
payload: buildPayload(),
savedViewId: targetType === 'view' ? savedViewId : undefined,
}),
onSuccess: (data) => { setPreview(data); setStep('preview'); },
});
const applyMut = useMutation({
mutationFn: () => bulkApply({
type: actionType,
payload: buildPayload(),
savedViewId: targetType === 'view' ? savedViewId : undefined,
}),
onSuccess: onClose,
});
const setP = (key: string, val: string) => setPayload((p) => ({ ...p, [key]: val }));
return (
<Modal title="Bulk Change" onClose={onClose} width={640}>
{step === 'configure' && (
<>
<div className={f.field}>
<label className={f.label}>Action</label>
<select className={f.select} value={actionType} onChange={(e) => { setActionType(e.target.value as BulkActionType); setPayload({}); }}>
{(Object.keys(ACTION_LABELS) as BulkActionType[]).map((k) => (
<option key={k} value={k}>{ACTION_LABELS[k]}</option>
))}
</select>
</div>
{actionType === 'SET_PRIVACY' && (
<div className={f.field}>
<label className={f.label}>Privacy Status</label>
<select className={f.select} value={payload.privacyStatus ?? 'PUBLIC'} onChange={(e) => setP('privacyStatus', e.target.value)}>
<option value="PUBLIC">Public</option>
<option value="PRIVATE">Private</option>
<option value="UNLISTED">Unlisted</option>
</select>
</div>
)}
{actionType === 'SET_TEMPLATE' && (
<div className={f.field}>
<label className={f.label}>Template</label>
<select className={f.select} value={payload.templateId ?? ''} onChange={(e) => setP('templateId', e.target.value)}>
<option value=""> select </option>
{templates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
)}
{(actionType === 'ADD_TAGS' || actionType === 'REMOVE_TAGS') && (
<div className={f.field}>
<label className={f.label}>Tags (comma-separated)</label>
<input className={f.input} value={payload.tags ?? ''} onChange={(e) => setP('tags', e.target.value)} placeholder="tag1, tag2, tag3" />
</div>
)}
{actionType === 'SEARCH_REPLACE_TITLE' && (
<div className={f.row}>
<div className={f.field}>
<label className={f.label}>Search</label>
<input className={f.input} value={payload.search ?? ''} onChange={(e) => setP('search', e.target.value)} placeholder="text to find" />
</div>
<div className={f.field}>
<label className={f.label}>Replace with</label>
<input className={f.input} value={payload.replace ?? ''} onChange={(e) => setP('replace', e.target.value)} placeholder="replacement" />
</div>
</div>
)}
<div className={f.field}>
<label className={f.label}>Target Videos</label>
<select className={f.select} value={targetType} onChange={(e) => setTargetType(e.target.value as 'all' | 'view')}>
<option value="all">All videos in team</option>
<option value="view">Saved view</option>
</select>
</div>
{targetType === 'view' && (
<div className={f.field}>
<label className={f.label}>Saved View</label>
<select className={f.select} value={savedViewId} onChange={(e) => setSavedViewId(e.target.value)}>
<option value=""> select </option>
{savedViews.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
)}
{previewMut.isError && <p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>Preview failed check the backend logs.</p>}
<div className={f.actions}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => previewMut.mutate()} disabled={previewMut.isPending}>
{previewMut.isPending ? <Loader2 size={14} /> : <Eye size={14} />}
Preview Changes
</button>
</div>
</>
)}
{step === 'preview' && preview && (
<>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-muted)' }}>
<strong>{preview.count}</strong> video{preview.count !== 1 ? 's' : ''} will be affected by <strong>{ACTION_LABELS[preview.type]}</strong>.
</p>
<div style={{ maxHeight: 320, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-xs)' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)', background: 'var(--color-surface-offset)' }}>
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Video</th>
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Before</th>
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>After</th>
</tr>
</thead>
<tbody>
{preview.previews.slice(0, 50).map((p) => (
<tr key={p.videoId} style={{ borderBottom: '1px solid var(--color-divider)' }}>
<td style={{ padding: '8px 12px', color: 'var(--color-text-muted)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.before.title}
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-faint)' }}>
{preview.type === 'SET_PRIVACY' && p.before.privacyStatus}
{preview.type === 'ADD_TAGS' && (p.before.tags ?? []).join(', ')}
{preview.type === 'REMOVE_TAGS' && (p.before.tags ?? []).join(', ')}
{preview.type === 'SEARCH_REPLACE_TITLE' && p.before.title}
{preview.type === 'SET_TEMPLATE' && (p.before.templateId ?? '—')}
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-primary)', fontWeight: 600 }}>
{preview.type === 'SET_PRIVACY' && p.after.privacyStatus}
{preview.type === 'ADD_TAGS' && (p.after.tags ?? []).join(', ')}
{preview.type === 'REMOVE_TAGS' && (p.after.tags ?? []).join(', ')}
{preview.type === 'SEARCH_REPLACE_TITLE' && p.after.title}
{preview.type === 'SET_TEMPLATE' && (p.after.templateId ?? '—')}
</td>
</tr>
))}
</tbody>
</table>
{preview.previews.length > 50 && (
<p style={{ padding: '8px 12px', color: 'var(--color-text-faint)', fontSize: 'var(--text-xs)' }}>
+ {preview.previews.length - 50} more videos not shown
</p>
)}
</div>
{applyMut.isError && <p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>Apply failed check the backend logs.</p>}
<div className={f.actions}>
<button className="btn btn-secondary" onClick={() => setStep('configure')}>Back</button>
<button className="btn btn-primary" onClick={() => applyMut.mutate()} disabled={applyMut.isPending}>
{applyMut.isPending ? <Loader2 size={14} /> : null}
Apply to {preview.count} Video{preview.count !== 1 ? 's' : ''}
</button>
</div>
</>
)}
</Modal>
);
}
// ─── Sync Status Indicator ────────────────────────────────────────────────────
function SyncStatusIndicator() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data } = useQuery({
queryKey: ['syncQueueStatus'],
queryFn: fetchSyncQueueStatus,
refetchInterval: (query) => {
const d = query.state.data;
if (d?.active.length) return 3_000;
if (d?.waiting.length) return 8_000;
return 30_000;
},
});
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const activeCount = data?.active.length ?? 0;
const waitingCount = data?.waiting.length ?? 0;
const pendingCount = activeCount + waitingCount;
const hasFailures = (data?.recentFailed.length ?? 0) > 0;
function formatRelative(iso: string | null) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
return `${Math.floor(mins / 60)}h ago`;
}
return (
<div className={styles.syncWrap} ref={ref}>
<button
className={`${styles.syncBtn} ${pendingCount > 0 ? styles.syncBtnActive : ''} ${hasFailures && pendingCount === 0 ? styles.syncBtnFailed : ''}`}
onClick={() => setOpen((o) => !o)}
title="YouTube sync queue"
>
{activeCount > 0
? <Loader size={18} className={styles.syncSpinner} />
: <CloudUpload size={18} />}
{pendingCount > 0 && <span className={styles.syncBadge}>{pendingCount}</span>}
</button>
{open && (
<div className={styles.syncDropdown}>
<p className={styles.syncDropdownTitle}>YouTube sync queue</p>
{activeCount === 0 && waitingCount === 0 && data?.recentFailed.length === 0 && data?.recentCompleted.length === 0 && (
<p className={styles.syncEmpty}>Queue is empty.</p>
)}
{activeCount > 0 && (
<div className={styles.syncSection}>
<p className={styles.syncSectionLabel}>Processing</p>
{data!.active.map((j) => (
<div key={j.jobId} className={styles.syncRow}>
<Loader size={13} className={styles.syncSpinner} style={{ color: 'var(--color-primary)', flexShrink: 0 }} />
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
</div>
))}
</div>
)}
{waitingCount > 0 && (
<div className={styles.syncSection}>
<p className={styles.syncSectionLabel}>Queued</p>
{data!.waiting.map((j) => (
<div key={j.jobId} className={styles.syncRow}>
<Clock size={13} style={{ color: 'var(--color-text-faint)', flexShrink: 0 }} />
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
</div>
))}
</div>
)}
{(data?.recentFailed.length ?? 0) > 0 && (
<div className={styles.syncSection}>
<p className={styles.syncSectionLabel}>Failed</p>
{data!.recentFailed.map((j) => (
<div key={j.jobId} className={styles.syncRow}>
<XCircle size={13} style={{ color: 'var(--color-error)', flexShrink: 0 }} />
<div className={styles.syncRowInfo}>
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
{j.failedReason && <span className={styles.syncRowSub}>{j.failedReason}</span>}
</div>
<span className={styles.syncRowTime}>{formatRelative(j.failedAt)}</span>
</div>
))}
</div>
)}
{(data?.recentCompleted.length ?? 0) > 0 && (
<div className={styles.syncSection}>
<p className={styles.syncSectionLabel}>Completed</p>
{data!.recentCompleted.map((j) => (
<div key={j.jobId} className={styles.syncRow}>
<CheckCircle2 size={13} style={{ color: 'var(--color-success, #22c55e)', flexShrink: 0 }} />
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
<span className={styles.syncRowTime}>{formatRelative(j.completedAt)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// ─── Header ───────────────────────────────────────────────────────────────────
export default function Header() {
const { toggleSidebar } = useUIStore();
const { theme, toggleTheme } = useTheme();
const { user, clearAuth } = useAuthStore();
const router = useRouter();
const [bulkOpen, setBulkOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
async function handleLogout() {
try { await apiClient.post('/auth/logout'); } catch {}
clearAuth();
document.cookie = 'sf_session=; path=/; max-age=0';
router.replace('/login');
}
function handleSearch(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
const q = (e.target as HTMLInputElement).value.trim();
if (q) router.push(`/videos?search=${encodeURIComponent(q)}`);
}
}
return (
<>
<header className={styles.header}>
<div className={styles.left}>
<button onClick={toggleSidebar} className={styles.menuBtn}>
<Menu size={20} />
</button>
<label className={styles.search}>
<Search size={18} />
<input ref={searchRef} type="text" placeholder="Search videos… (Enter)" onKeyDown={handleSearch} />
</label>
</div>
<div className={styles.right}>
<SyncStatusIndicator />
<button className={styles.btnSecondary} onClick={toggleTheme}>
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
<span>Theme</span>
</button>
<button className={styles.btnSecondary} onClick={() => setBulkOpen(true)}>
<Eye size={18} />
<span>Bulk change</span>
</button>
{user && (
<div className={styles.userArea}>
<span className={styles.teamBadge}>{user.teamRole}</span>
<span className={styles.userName}>{user.name}</span>
<button onClick={handleLogout} className={styles.logoutBtn} title="Sign out">
<LogOut size={16} />
</button>
</div>
)}
</div>
</header>
{bulkOpen && <BulkModal onClose={() => setBulkOpen(false)} />}
</>
);
}