'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; // ── 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 ( {sev} ); } 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(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>(new Set()); const [expandedFix, setExpandedFix] = useState>(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); if (next.has(id)) next.delete(id); else 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); if (next.has(id)) next.delete(id); else 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 (
{/* Header */}
Quality

Metadata Linting

{rerunMsg && {rerunMsg}}
{/* Summary cards */}
Open issues {results.length} {errorCount} errors · {warnCount} warnings
Videos affected {videosAffected} unique videos with at least one open issue
Errors 0 ? styles.cardValueError : ''}`}>{errorCount} require immediate attention
{/* Rule configuration */}
Rule Configuration
{rulesOpen && ( <>
{RULE_CATALOG.map((rule) => { const enabled = !disabledRules.includes(rule.code); return (
toggleRule(rule.code)} /> {rule.label} {rule.desc}
); })}
{rulesSaved && ( Saved )}
)}
{/* Issue table */}
Open Issues setColumns(v, o)} defaultVisible={LINTING_DEFAULTS.visible} defaultOrder={LINTING_DEFAULTS.order} />
{/* Filter bar */}
{(['ALL', 'ERROR', 'WARNING'] as const).map((s) => ( ))}
setSearch(e.target.value)} /> {selected.size > 0 && (
)}
{isLoading ? (
Loading issues…
) : filtered.length === 0 ? (
No open issues All metadata looks good! Re-run lint to check for new problems.
) : (
{show('severity') && } {show('rule') && } {show('video') && } {show('field') && } {show('message') && } {show('date') && } {filtered.map((r) => { const ruleMeta = RULE_CATALOG.find((x) => x.code === r.ruleCode); const fixOpen = expandedFix.has(r.id); return ( {show('severity') && } {show('rule') && } {show('video') && ( )} {show('field') && ( )} {show('message') && ( )} {show('date') && } ); })}
0} onChange={toggleAll} /> SeverityRuleVideoFieldMessageDate
toggleSelect(r.id)} /> {ruleMeta?.label ?? r.ruleCode} {r.video.title} {r.targetField ? {r.targetField} : }
{r.message}
{r.fixSuggestion && ( <> {fixOpen && (
{r.fixSuggestion}
)} )}
{fmtDate(r.createdAt)}
)}
); }