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,456 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import Link from 'next/link';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle, CheckCircle, ChevronDown, ChevronUp,
RefreshCw, Loader2, Info,
} from 'lucide-react';
import { useAuthStore } from '@/store/useAuthStore';
import {
fetchLintResults, resolveLintResult, bulkResolveLintResults,
runLintForTeam, recomputeLintStatus, fetchTeamSettings, updateTeamSettings,
} from '@/lib/api';
import ColumnPicker from '@/components/shared/ColumnPicker';
import { useColumnPreferences } from '@/hooks/useColumnPreferences';
import styles from './page.module.css';
import f from '@/components/shared/FormField.module.css';
const LINTING_COLUMN_DEFS = [
{ id: 'severity', label: 'Severity' },
{ id: 'rule', label: 'Rule' },
{ id: 'video', label: 'Video', required: true },
{ id: 'field', label: 'Field' },
{ id: 'message', label: 'Message' },
{ id: 'date', label: 'Date' },
];
const LINTING_DEFAULTS = {
visible: ['severity', 'rule', 'video', 'field', 'message', 'date'],
order: ['severity', 'rule', 'video', 'field', 'message', 'date'],
};
// ── Static rule catalog ────────────────────────────────────────────────────────
const RULE_CATALOG = [
{ code: 'TITLE_WEAK', severity: 'WARNING', label: 'Weak title', desc: 'Title contains weak/filler words that may hurt click-through rate.' },
{ code: 'TITLE_TOO_LONG', severity: 'WARNING', label: 'Title too long', desc: 'Title exceeds the 100-character YouTube limit.' },
{ code: 'DESC_MISSING_CTA', severity: 'WARNING', label: 'Missing CTA', desc: 'Description has no call-to-action (subscribe, follow, etc.).' },
{ code: 'DESC_MISSING_CHAPTERS', severity: 'WARNING', label: 'Missing chapters', desc: 'Description has no timestamp chapters for navigation.' },
{ code: 'DESC_EMPTY_PLACEHOLDER', severity: 'ERROR', label: 'Unfilled placeholder', desc: 'Description still contains an unfilled template placeholder like [TODO].' },
{ code: 'DESC_DUPLICATE_HASHTAG', severity: 'WARNING', label: 'Duplicate hashtag', desc: 'The same hashtag appears more than once in the description.' },
{ code: 'DESC_REQUIRED_LINK_MISSING', severity: 'ERROR', label: 'Required link missing', desc: 'A required link (e.g. affiliate, sponsor) is absent from the description.' },
{ code: 'DESC_OUTDATED_SPONSOR_COPY', severity: 'ERROR', label: 'Outdated sponsor copy', desc: 'A campaign block with an expired end date is still active in the description.' },
{ code: 'REMOTE_CONFLICT', severity: 'ERROR', label: 'Remote conflict', desc: 'The video was modified on YouTube after the last sync — local and remote diverged.' },
] as const;
type RuleCode = typeof RULE_CATALOG[number]['code'];
// ── Helpers ───────────────────────────────────────────────────────────────────
function SevBadge({ sev }: { sev: string }) {
const cls = sev === 'ERROR' ? styles.sevERROR : sev === 'WARNING' ? styles.sevWARNING : styles.sevINFO;
const Icon = sev === 'ERROR' ? AlertTriangle : sev === 'WARNING' ? AlertTriangle : Info;
return (
<span className={`${styles.sevBadge} ${cls}`}>
<Icon size={11} />
{sev}
</span>
);
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function LintingPage() {
const user = useAuthStore((s) => s.user);
const teamId = user?.teamId ?? '';
const qc = useQueryClient();
const { visible: colVisible, order: colOrder, setColumns } = useColumnPreferences('linting');
const show = (id: string) => colVisible.includes(id);
// ── Queries ──
const { data: results = [], isLoading } = useQuery({
queryKey: ['lintResults'],
queryFn: () => fetchLintResults(),
refetchInterval: 30_000,
});
const { data: settings } = useQuery({
queryKey: ['teamSettings', teamId],
queryFn: () => fetchTeamSettings(teamId),
enabled: !!teamId,
});
// ── Rule config state ──
const [rulesOpen, setRulesOpen] = useState(false);
const [localDisabled, setLocalDisabled] = useState<string[] | null>(null);
const [rulesSaved, setRulesSaved] = useState(false);
const [rulesSaving, setRulesSaving] = useState(false);
const disabledRules = localDisabled ?? settings?.disabledLintRules ?? [];
function toggleRule(code: string) {
const current = disabledRules;
setLocalDisabled(
current.includes(code) ? current.filter((c) => c !== code) : [...current, code],
);
setRulesSaved(false);
}
async function saveRules() {
setRulesSaving(true);
try {
await updateTeamSettings(teamId, { disabledLintRules: disabledRules });
qc.invalidateQueries({ queryKey: ['teamSettings', teamId] });
qc.invalidateQueries({ queryKey: ['lintResults'] });
setRulesSaved(true);
setTimeout(() => setRulesSaved(false), 3000);
} finally {
setRulesSaving(false);
}
}
// ── Re-run lint state ──
const [rerunMsg, setRerunMsg] = useState('');
const [rerunning, setRerunning] = useState(false);
async function handleRerun() {
setRerunning(true);
setRerunMsg('');
try {
const res = await runLintForTeam();
setRerunMsg(`Queued ${res.queued} video${res.queued !== 1 ? 's' : ''}`);
} catch {
setRerunMsg('Failed to queue');
} finally {
setRerunning(false);
}
}
// Silently heal any stale lintStatus badges whenever this page is opened
const { mutate: healLintStatus } = useMutation({ mutationFn: recomputeLintStatus });
useEffect(() => { healLintStatus(); }, []);
// ── Filters ──
const [sevFilter, setSevFilter] = useState<'ALL' | 'ERROR' | 'WARNING'>('ALL');
const [ruleFilter, setRuleFilter] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [expandedFix, setExpandedFix] = useState<Set<string>>(new Set());
const filtered = useMemo(() => {
return results.filter((r) => {
if (sevFilter !== 'ALL' && r.severity !== sevFilter) return false;
if (ruleFilter && r.ruleCode !== ruleFilter) return false;
if (search && !r.video.title.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [results, sevFilter, ruleFilter, search]);
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleAll() {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((r) => r.id)));
}
}
function toggleFix(id: string) {
setExpandedFix((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
// ── Mutations ──
const resolveOneMutation = useMutation({
mutationFn: resolveLintResult,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['lintResults'] });
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
const bulkResolveMutation = useMutation({
mutationFn: bulkResolveLintResults,
onSuccess: () => {
setSelected(new Set());
qc.invalidateQueries({ queryKey: ['lintResults'] });
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
// ── Summary stats ──
const errorCount = results.filter((r) => r.severity === 'ERROR').length;
const warnCount = results.filter((r) => r.severity === 'WARNING').length;
const videosAffected = new Set(results.map((r) => r.video.id)).size;
return (
<div className={styles.container}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.eyebrow}>Quality</span>
<h1>Metadata Linting</h1>
</div>
<div className={styles.headerRight}>
<button
className="btn btn-secondary"
onClick={handleRerun}
disabled={rerunning}
>
{rerunning
? <><Loader2 size={14} className={styles.spin} /> Running</>
: <><RefreshCw size={14} /> Re-run lint</>}
</button>
{rerunMsg && <span className={styles.rerunResult}>{rerunMsg}</span>}
</div>
</div>
{/* Summary cards */}
<div className={styles.cards}>
<div className={styles.card}>
<span className={styles.cardLabel}>Open issues</span>
<span className={styles.cardValue}>{results.length}</span>
<span className={styles.cardSub}>{errorCount} errors · {warnCount} warnings</span>
</div>
<div className={styles.card}>
<span className={styles.cardLabel}>Videos affected</span>
<span className={styles.cardValue}>{videosAffected}</span>
<span className={styles.cardSub}>unique videos with at least one open issue</span>
</div>
<div className={styles.card}>
<span className={styles.cardLabel}>Errors</span>
<span className={`${styles.cardValue} ${errorCount > 0 ? styles.cardValueError : ''}`}>{errorCount}</span>
<span className={styles.cardSub}>require immediate attention</span>
</div>
</div>
{/* Rule configuration */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Rule Configuration</span>
<button className={styles.collapseBtn} onClick={() => setRulesOpen((o) => !o)}>
{rulesOpen ? <><ChevronUp size={14} /> Collapse</> : <><ChevronDown size={14} /> Expand</>}
</button>
</div>
{rulesOpen && (
<>
<div className={styles.rulesPanel}>
{RULE_CATALOG.map((rule) => {
const enabled = !disabledRules.includes(rule.code);
return (
<div key={rule.code} className={styles.ruleRow}>
<input
type="checkbox"
className={styles.ruleToggle}
checked={enabled}
onChange={() => toggleRule(rule.code)}
/>
<span className={`${styles.ruleLabel} ${!enabled ? styles.ruleLabelDisabled : ''}`}>
{rule.label}
</span>
<SevBadge sev={rule.severity} />
<span className={styles.ruleDesc}>{rule.desc}</span>
</div>
);
})}
</div>
<div className={styles.rulesSaveRow}>
<button
className="btn btn-primary"
onClick={saveRules}
disabled={rulesSaving}
>
{rulesSaving ? 'Saving…' : 'Save rule config'}
</button>
{rulesSaved && (
<span className={styles.rulesSaved}>
<CheckCircle size={13} /> Saved
</span>
)}
</div>
</>
)}
</div>
{/* Issue table */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Open Issues</span>
<ColumnPicker
columns={LINTING_COLUMN_DEFS}
visible={colVisible}
order={colOrder}
onChange={(v, o) => setColumns(v, o)}
defaultVisible={LINTING_DEFAULTS.visible}
defaultOrder={LINTING_DEFAULTS.order}
/>
</div>
{/* Filter bar */}
<div className={styles.filterBar}>
<div className={styles.sevPills}>
{(['ALL', 'ERROR', 'WARNING'] as const).map((s) => (
<button
key={s}
className={`${styles.sevPill} ${sevFilter === s ? styles.sevPillActive : ''}`}
onClick={() => setSevFilter(s)}
>
{s === 'ALL' ? 'All' : s === 'ERROR' ? 'Errors' : 'Warnings'}
{s !== 'ALL' && (
<> · {results.filter((r) => r.severity === s).length}</>
)}
</button>
))}
</div>
<select
className={`${f.input} ${f.select} ${styles.ruleFilter}`}
value={ruleFilter}
onChange={(e) => setRuleFilter(e.target.value)}
>
<option value="">All rules</option>
{RULE_CATALOG.map((r) => (
<option key={r.code} value={r.code}>{r.label}</option>
))}
</select>
<input
className={`${f.input} ${styles.searchInput}`}
placeholder="Search video title…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{selected.size > 0 && (
<div className={styles.bulkActions}>
<button
className="btn btn-primary"
onClick={() => bulkResolveMutation.mutate([...selected])}
disabled={bulkResolveMutation.isPending}
>
{bulkResolveMutation.isPending
? 'Resolving…'
: `Resolve selected (${selected.size})`}
</button>
</div>
)}
</div>
{isLoading ? (
<div className={styles.state}>
<Loader2 size={16} className={styles.spin} />
Loading issues
</div>
) : filtered.length === 0 ? (
<div className={styles.empty}>
<CheckCircle size={40} className={styles.emptyIcon} />
<span className={styles.emptyTitle}>No open issues</span>
<span>All metadata looks good! Re-run lint to check for new problems.</span>
</div>
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.checkboxCell}>
<input
type="checkbox"
checked={selected.size === filtered.length && filtered.length > 0}
onChange={toggleAll}
/>
</th>
{show('severity') && <th>Severity</th>}
{show('rule') && <th>Rule</th>}
{show('video') && <th>Video</th>}
{show('field') && <th>Field</th>}
{show('message') && <th>Message</th>}
{show('date') && <th>Date</th>}
<th></th>
</tr>
</thead>
<tbody>
{filtered.map((r) => {
const ruleMeta = RULE_CATALOG.find((x) => x.code === r.ruleCode);
const fixOpen = expandedFix.has(r.id);
return (
<tr key={r.id}>
<td className={styles.checkboxCell}>
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={() => toggleSelect(r.id)}
/>
</td>
{show('severity') && <td><SevBadge sev={r.severity} /></td>}
{show('rule') && <td>{ruleMeta?.label ?? r.ruleCode}</td>}
{show('video') && (
<td>
<Link href={`/videos/${r.video.id}`} className={styles.videoTitle}>
{r.video.title}
</Link>
</td>
)}
{show('field') && (
<td>
{r.targetField
? <span className={styles.targetChip}>{r.targetField}</span>
: <span style={{ color: 'var(--color-text-faint)' }}></span>}
</td>
)}
{show('message') && (
<td className={styles.messageCell}>
<div className={styles.message}>{r.message}</div>
{r.fixSuggestion && (
<>
<button className={styles.fixBtn} onClick={() => toggleFix(r.id)}>
{fixOpen
? <><ChevronUp size={11} /> Hide suggestion</>
: <><ChevronDown size={11} /> Show fix suggestion</>}
</button>
{fixOpen && (
<div className={styles.fixSuggestion}>{r.fixSuggestion}</div>
)}
</>
)}
</td>
)}
{show('date') && <td className={styles.dateCell}>{fmtDate(r.createdAt)}</td>}
<td>
<button
className={`btn btn-secondary ${styles.resolveBtn}`}
onClick={() => resolveOneMutation.mutate(r.id)}
disabled={resolveOneMutation.isPending}
>
Resolve
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}