Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user