onSelect(row.url)}
+ onClick={() => onSelect?.(row.url)}
className={`cursor-pointer transition-colors ${scoreRowBg(row.performance)} ${isSelected ? 'ring-2 ring-inset ring-blue-500' : ''}`}
>
@@ -127,7 +142,7 @@ export default function MultiPageTable({ byUrl, selectedUrl, onSelect }) {
) : (
- {val != null ? c.fmt(val) : '—'}
+ {val != null && c.fmt ? c.fmt(val) : '—'}
)}
{hoveredCell === cellId && c.isScore && val != null && (
diff --git a/web/src/components/lighthouse/QuickWinCard.jsx b/web/src/components/lighthouse/QuickWinCard.tsx
similarity index 82%
rename from web/src/components/lighthouse/QuickWinCard.jsx
rename to web/src/components/lighthouse/QuickWinCard.tsx
index 60836307..f57989d2 100644
--- a/web/src/components/lighthouse/QuickWinCard.jsx
+++ b/web/src/components/lighthouse/QuickWinCard.tsx
@@ -1,14 +1,20 @@
import { useState } from 'react';
-import { CheckCircle, XCircle, ChevronDown, ChevronUp, Zap, Image, Code2, Search, Shield, Clock } from 'lucide-react';
+import { CheckCircle, XCircle, ChevronDown, ChevronUp, Zap, Image, Code2, Search, Shield, Clock, type LucideIcon } from 'lucide-react';
+import type { LighthouseQuickWin } from '@/types/report';
-const ICON_MAP = { Zap, Image, Code2, Search, Shield, Clock };
+const ICON_MAP: Record = { Zap, Image, Code2, Search, Shield, Clock };
-function WinIcon({ iconKey }) {
- const Icon = ICON_MAP[iconKey] || Zap;
+export interface QuickWinCardProps {
+ win: LighthouseQuickWin;
+ passed: boolean;
+}
+
+function WinIcon({ iconKey }: { iconKey?: string }) {
+ const Icon = (iconKey && ICON_MAP[iconKey]) || Zap;
return ;
}
-export default function QuickWinCard({ win, passed }) {
+export default function QuickWinCard({ win, passed }: QuickWinCardProps) {
const [open, setOpen] = useState(false);
return (
diff --git a/web/src/components/lighthouse/ScoreRing.jsx b/web/src/components/lighthouse/ScoreRing.tsx
similarity index 87%
rename from web/src/components/lighthouse/ScoreRing.jsx
rename to web/src/components/lighthouse/ScoreRing.tsx
index 0fa59171..1261d433 100644
--- a/web/src/components/lighthouse/ScoreRing.jsx
+++ b/web/src/components/lighthouse/ScoreRing.tsx
@@ -1,6 +1,11 @@
import { scoreRingColor } from '../../utils/lighthouseUtils';
-export default function ScoreRing({ label, score }) {
+export interface ScoreRingProps {
+ label: string;
+ score: number | null | undefined;
+}
+
+export default function ScoreRing({ label, score }: ScoreRingProps) {
const color = scoreRingColor(score);
const displayScore = score != null ? score : '—';
diff --git a/web/src/components/lighthouse/ThresholdBar.jsx b/web/src/components/lighthouse/ThresholdBar.tsx
similarity index 92%
rename from web/src/components/lighthouse/ThresholdBar.jsx
rename to web/src/components/lighthouse/ThresholdBar.tsx
index 35f13d81..d865a3b0 100644
--- a/web/src/components/lighthouse/ThresholdBar.jsx
+++ b/web/src/components/lighthouse/ThresholdBar.tsx
@@ -1,11 +1,16 @@
import { useState, useEffect, useRef } from 'react';
import { METRIC_THRESHOLDS, metricStatus, formatMetric } from '../../utils/lighthouseUtils';
-export default function ThresholdBar({ metricKey, value }) {
+export interface ThresholdBarProps {
+ metricKey: keyof typeof METRIC_THRESHOLDS;
+ value: number | null | undefined;
+}
+
+export default function ThresholdBar({ metricKey, value }: ThresholdBarProps) {
const t = METRIC_THRESHOLDS[metricKey];
const [hovered, setHovered] = useState(false);
const [mounted, setMounted] = useState(false);
- const barRef = useRef(null);
+ const barRef = useRef(null);
useEffect(() => {
const id = setTimeout(() => setMounted(true), 50);
diff --git a/web/src/components/lighthouse/index.js b/web/src/components/lighthouse/index.ts
similarity index 100%
rename from web/src/components/lighthouse/index.js
rename to web/src/components/lighthouse/index.ts
diff --git a/web/src/components/links/CharBar.jsx b/web/src/components/links/CharBar.tsx
similarity index 73%
rename from web/src/components/links/CharBar.jsx
rename to web/src/components/links/CharBar.tsx
index 44c84f56..ba4d8656 100644
--- a/web/src/components/links/CharBar.jsx
+++ b/web/src/components/links/CharBar.tsx
@@ -1,4 +1,10 @@
-export default function CharBar({ len, max, colorFn }) {
+export interface CharBarProps {
+ len: number;
+ max: number;
+ colorFn: (len: number) => string;
+}
+
+export default function CharBar({ len, max, colorFn }: CharBarProps) {
const pct = Math.min(100, (len / max) * 100);
return (
diff --git a/web/src/components/links/CopyBtn.jsx b/web/src/components/links/CopyBtn.tsx
similarity index 80%
rename from web/src/components/links/CopyBtn.jsx
rename to web/src/components/links/CopyBtn.tsx
index 2bccc076..94136c47 100644
--- a/web/src/components/links/CopyBtn.jsx
+++ b/web/src/components/links/CopyBtn.tsx
@@ -2,11 +2,16 @@ import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { strings } from '../../lib/strings';
-export default function CopyBtn({ text, className = '' }) {
+export interface CopyBtnProps {
+ text?: string | null;
+ className?: string;
+}
+
+export default function CopyBtn({ text, className = '' }: CopyBtnProps) {
const c = strings.components.copyBtn;
const [copied, setCopied] = useState(false);
- const copy = () => {
+ const copy = (): void => {
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
diff --git a/web/src/components/links/HeadingPills.jsx b/web/src/components/links/HeadingPills.tsx
similarity index 83%
rename from web/src/components/links/HeadingPills.jsx
rename to web/src/components/links/HeadingPills.tsx
index 20c71d5e..ba5fb304 100644
--- a/web/src/components/links/HeadingPills.jsx
+++ b/web/src/components/links/HeadingPills.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { ChevronRight, AlertTriangle } from 'lucide-react';
-const H_COLORS = {
+const H_COLORS: Record = {
h1: 'bg-blue-500/20 text-link-soft border-blue-500/30',
h2: 'bg-purple-500/20 text-purple-800 dark:text-purple-300 border-purple-500/30',
h3: 'bg-teal-500/20 text-teal-800 dark:text-teal-300 border-teal-500/30',
@@ -10,11 +10,16 @@ const H_COLORS = {
h6: 'bg-brand-700/20 text-muted-foreground border-brand-700/30',
};
-export default function HeadingPills({ sequence }) {
- const pills = useMemo(() => {
+export interface HeadingPillsProps {
+ sequence: string | null | undefined;
+}
+
+export default function HeadingPills({ sequence }: HeadingPillsProps) {
+ const pills = useMemo((): string[] => {
if (!sequence) return [];
try {
- return JSON.parse(sequence);
+ const parsed = JSON.parse(sequence) as unknown;
+ return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return typeof sequence === 'string'
? sequence.split(',').map((s) => s.trim()).filter(Boolean)
diff --git a/web/src/components/links/InlineRing.jsx b/web/src/components/links/InlineRing.tsx
similarity index 81%
rename from web/src/components/links/InlineRing.jsx
rename to web/src/components/links/InlineRing.tsx
index 6c52449f..499874a1 100644
--- a/web/src/components/links/InlineRing.jsx
+++ b/web/src/components/links/InlineRing.tsx
@@ -1,4 +1,9 @@
-export default function InlineRing({ pct, color = '#3b82f6' }) {
+export interface InlineRingProps {
+ pct: number;
+ color?: string;
+}
+
+export default function InlineRing({ pct, color = '#3b82f6' }: InlineRingProps) {
const r = 14;
const circ = 2 * Math.PI * r;
const dash = (Math.min(100, pct) / 100) * circ;
diff --git a/web/src/components/links/InspectorTabs.jsx b/web/src/components/links/InspectorTabs.tsx
similarity index 84%
rename from web/src/components/links/InspectorTabs.jsx
rename to web/src/components/links/InspectorTabs.tsx
index f5f81071..a7bbd6c7 100644
--- a/web/src/components/links/InspectorTabs.jsx
+++ b/web/src/components/links/InspectorTabs.tsx
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react';
import { Gauge, Share2, Code2, Shield, AlertTriangle, FileBarChart } from 'lucide-react';
+import type { InspectorDetails, InspectorIssueRow, LinkDetail, LinkLighthouseData } from '@/types/report';
import { strings, format } from '../../lib/strings';
import { SEO_ISSUE_RECOMMENDATIONS } from '../../utils/linkUtils';
import OverviewTab from './tabs/OverviewTab';
@@ -20,9 +21,9 @@ const TABS = [
{ id: 'issues', label: ci.issues, icon: },
];
-function buildAllIssues(inspectorDetails) {
+function buildAllIssues(inspectorDetails: InspectorDetails | null): InspectorIssueRow[] {
if (!inspectorDetails) return [];
- const list = [];
+ const list: InspectorIssueRow[] = [];
inspectorDetails.broken.forEach((i) =>
list.push({ severity: 'Critical', message: format(ci.brokenMessage, { status: i.status }), type: 'broken' })
);
@@ -30,21 +31,27 @@ function buildAllIssues(inspectorDetails) {
list.push({ severity: 'High', message: format(ci.redirectMessage, { status: i.status }), type: 'redirect' })
);
inspectorDetails.seoIssues.forEach((i) =>
- list.push({ severity: 'High', message: i.message, type: 'seo', recommendation: SEO_ISSUE_RECOMMENDATIONS[i.type] })
+ list.push({ severity: 'High', message: i.message ?? '', type: 'seo', recommendation: i.type ? SEO_ISSUE_RECOMMENDATIONS[i.type as keyof typeof SEO_ISSUE_RECOMMENDATIONS] : undefined })
);
inspectorDetails.contentFlags.forEach((i) =>
list.push({ severity: 'Medium', message: i.label, type: 'content' })
);
inspectorDetails.categoryIssues.forEach((i) =>
- list.push({ severity: i.priority || 'Medium', message: i.message, type: 'category' })
+ list.push({ severity: i.priority || 'Medium', message: i.message ?? '', type: 'category' })
);
inspectorDetails.securityFindings.forEach((i) =>
- list.push({ severity: i.severity || 'Medium', message: i.message, type: 'security' })
+ list.push({ severity: i.severity || 'Medium', message: i.message ?? '', type: 'security' })
);
return list;
}
-export default function InspectorTabs({ link, lhData, inspectorDetails }) {
+export interface InspectorTabsProps {
+ link: LinkDetail;
+ lhData?: LinkLighthouseData | null;
+ inspectorDetails: InspectorDetails | null;
+}
+
+export default function InspectorTabs({ link, lhData, inspectorDetails }: InspectorTabsProps) {
const [activeTab, setActiveTab] = useState('overview');
const effectiveLh = link?.lighthouse || lhData;
diff --git a/web/src/components/links/MiniBar.jsx b/web/src/components/links/MiniBar.tsx
similarity index 80%
rename from web/src/components/links/MiniBar.jsx
rename to web/src/components/links/MiniBar.tsx
index 7877c46e..78bf2b89 100644
--- a/web/src/components/links/MiniBar.jsx
+++ b/web/src/components/links/MiniBar.tsx
@@ -1,4 +1,11 @@
-export default function MiniBar({ value, total, color = 'bg-blue-500', label }) {
+export interface MiniBarProps {
+ value: number;
+ total: number;
+ color?: string;
+ label?: string;
+}
+
+export default function MiniBar({ value, total, color = 'bg-blue-500', label }: MiniBarProps) {
const pct = total > 0 ? Math.min(100, (value / total) * 100) : 0;
return (
diff --git a/web/src/components/links/OGPreview.jsx b/web/src/components/links/OGPreview.tsx
similarity index 81%
rename from web/src/components/links/OGPreview.jsx
rename to web/src/components/links/OGPreview.tsx
index 5d7257d4..f7c576de 100644
--- a/web/src/components/links/OGPreview.jsx
+++ b/web/src/components/links/OGPreview.tsx
@@ -1,7 +1,14 @@
import { useMemo } from 'react';
import { Share2 } from 'lucide-react';
-export default function OGPreview({ url, ogTitle, ogDesc, ogImage }) {
+export interface OGPreviewProps {
+ url: string;
+ ogTitle?: string | null;
+ ogDesc?: string | null;
+ ogImage?: string | null;
+}
+
+export default function OGPreview({ url, ogTitle, ogDesc, ogImage }: OGPreviewProps) {
const domain = useMemo(() => {
try { return new URL(url).hostname; } catch { return url; }
}, [url]);
@@ -13,7 +20,7 @@ export default function OGPreview({ url, ogTitle, ogDesc, ogImage }) {
src={ogImage}
alt="OG"
className="w-full h-36 object-cover"
- onError={(e) => { e.target.style.display = 'none'; }}
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
) : (
diff --git a/web/src/components/links/RowTooltip.jsx b/web/src/components/links/RowTooltip.tsx
similarity index 79%
rename from web/src/components/links/RowTooltip.jsx
rename to web/src/components/links/RowTooltip.tsx
index e7c3bbdd..63d68561 100644
--- a/web/src/components/links/RowTooltip.jsx
+++ b/web/src/components/links/RowTooltip.tsx
@@ -1,7 +1,14 @@
import { useMemo } from 'react';
+import type { CSSProperties } from 'react';
+import type { LinkDetail } from '@/types/report';
import { parseKeywords, normaliseKw } from '../../utils/linkUtils';
-export default function RowTooltip({ link, style }) {
+export interface RowTooltipProps {
+ link: LinkDetail;
+ style?: CSSProperties;
+}
+
+export default function RowTooltip({ link, style }: RowTooltipProps) {
const kws = useMemo(() => parseKeywords(link.top_keywords).slice(0, 3), [link.top_keywords]);
return (
@@ -14,10 +21,10 @@ export default function RowTooltip({ link, style }) {
{link.meta_description}
)}
- {link.reading_level > 0 && (
+ {(link.reading_level ?? 0) > 0 && (
Grade {link.reading_level}
)}
- {link.word_count > 0 && (
+ {(link.word_count ?? 0) > 0 && (
{link.word_count} words
)}
diff --git a/web/src/components/links/SecHeaderRow.jsx b/web/src/components/links/SecHeaderRow.tsx
similarity index 93%
rename from web/src/components/links/SecHeaderRow.jsx
rename to web/src/components/links/SecHeaderRow.tsx
index af897c76..ef193230 100644
--- a/web/src/components/links/SecHeaderRow.jsx
+++ b/web/src/components/links/SecHeaderRow.tsx
@@ -1,7 +1,13 @@
import { useState } from 'react';
import { CheckCircle, XCircle, ChevronDown, ChevronUp } from 'lucide-react';
-export default function SecHeaderRow({ label, value, recommendation }) {
+export interface SecHeaderRowProps {
+ label: string;
+ value?: string | null;
+ recommendation?: string;
+}
+
+export default function SecHeaderRow({ label, value, recommendation }: SecHeaderRowProps) {
const [open, setOpen] = useState(false);
const present = !!value;
diff --git a/web/src/components/links/SortTh.jsx b/web/src/components/links/SortTh.tsx
similarity index 76%
rename from web/src/components/links/SortTh.jsx
rename to web/src/components/links/SortTh.tsx
index a34d383d..af39feb8 100644
--- a/web/src/components/links/SortTh.jsx
+++ b/web/src/components/links/SortTh.tsx
@@ -1,6 +1,15 @@
import { ChevronDown, ChevronUp, ArrowUpDown } from 'lucide-react';
-export default function SortTh({ label, field, sortBy, sortDesc, onSort, className = '' }) {
+export interface SortThProps {
+ label: string;
+ field: string;
+ sortBy: string;
+ sortDesc: boolean;
+ onSort: (field: string) => void;
+ className?: string;
+}
+
+export default function SortTh({ label, field, sortBy, sortDesc, onSort, className = '' }: SortThProps) {
const active = sortBy === field;
return (
) {
const ctx = chart.ctx;
const meta = chart.getDatasetMeta(0);
if (!meta?.data?.length) return;
@@ -38,7 +39,7 @@ const barValueLabelsPlugin = {
},
};
-function barOpts(yTitle, xTitle, tooltipUnit) {
+function barOpts(yTitle: string, xTitle: string | undefined, tooltipUnit: string | undefined) {
const lc = strings.components.linkTabs.content;
const vca = strings.views.contentAnalytics;
return {
@@ -48,7 +49,7 @@ function barOpts(yTitle, xTitle, tooltipUnit) {
legend: { display: false },
tooltip: {
callbacks: {
- label: (ctx) =>
+ label: (ctx: TooltipItem<'bar'>) =>
` ${
tooltipUnit === 'words'
? format(lc.tooltipWords, { n: ctx.raw?.toLocaleString() ?? ctx.raw })
@@ -64,7 +65,7 @@ function barOpts(yTitle, xTitle, tooltipUnit) {
};
}
-function barOptsH(suffixLabel) {
+function barOptsH(suffixLabel?: string) {
const sj = strings.common;
return {
indexAxis: 'y',
@@ -74,7 +75,7 @@ function barOptsH(suffixLabel) {
legend: { display: false },
tooltip: {
callbacks: {
- label: (ctx) =>
+ label: (ctx: TooltipItem<'bar'>) =>
` ${ctx.raw?.toLocaleString() ?? ctx.raw}${suffixLabel ? ` ${suffixLabel}` : ''}`,
},
},
@@ -96,7 +97,7 @@ function barOptsReadingDist() {
legend: { display: false },
tooltip: {
callbacks: {
- label: (ctx) => ` ${format(vca.chartTooltipCount, { n: ctx.raw?.toLocaleString() ?? ctx.raw })}`,
+ label: (ctx: TooltipItem<'bar'>) => ` ${format(vca.chartTooltipCount, { n: ctx.raw?.toLocaleString() ?? ctx.raw })}`,
},
},
},
@@ -117,7 +118,7 @@ function barOptsCompare() {
legend: { display: false },
tooltip: {
callbacks: {
- label: (ctx) => ` ${format(lc.tooltipWords, { n: ctx.raw?.toLocaleString() ?? ctx.raw })}`,
+ label: (ctx: TooltipItem<'bar'>) => ` ${format(lc.tooltipWords, { n: ctx.raw?.toLocaleString() ?? ctx.raw })}`,
},
},
},
@@ -137,7 +138,7 @@ function doughnutPageOpts() {
legend: { position: 'bottom', labels: { color: '#94a3b8', font: { size: 11 }, padding: 12 } },
tooltip: {
callbacks: {
- label: (ctx) => ` ${format(lc.tooltipDoughnut, { label: ctx.label, n: ctx.raw?.toLocaleString() })}`,
+ label: (ctx: TooltipItem<'bar'>) => ` ${format(lc.tooltipDoughnut, { label: ctx.label, n: ctx.raw?.toLocaleString() })}`,
},
},
},
@@ -153,7 +154,8 @@ function groupedSocialOpts() {
legend: { display: true, labels: { color: '#94a3b8', font: { size: 11 }, padding: 10 } },
tooltip: {
callbacks: {
- label: (ctx) => ` ${format(lc.tooltipGroupedPct, { dataset: ctx.dataset.label, pct: ctx.raw?.toFixed(0) })}`,
+ label: (ctx: TooltipItem<'bar'>) =>
+ ` ${format(lc.tooltipGroupedPct, { dataset: ctx.dataset.label, pct: Number(ctx.raw ?? 0).toFixed(0) })}`,
},
},
},
@@ -170,7 +172,7 @@ function groupedSocialOpts() {
};
}
-function qualityBarColors(labels) {
+function qualityBarColors(labels: string[]): string[] {
return labels.map((l) => {
const s = l.toLowerCase();
if (s.includes('missing') || s.includes('no h1')) return '#EF4444';
@@ -181,7 +183,7 @@ function qualityBarColors(labels) {
});
}
-function titleQualityIndex(len) {
+function titleQualityIndex(len: number | string | undefined): number {
const n = Number(len) || 0;
if (n === 0) return 0;
if (n < 30) return 1;
@@ -189,7 +191,7 @@ function titleQualityIndex(len) {
return 3;
}
-function metaQualityIndex(len) {
+function metaQualityIndex(len: number | string | undefined): number {
const n = Number(len) || 0;
if (n === 0) return 0;
if (n < 70) return 1;
@@ -197,20 +199,20 @@ function metaQualityIndex(len) {
return 3;
}
-function h1QualityIndex(count) {
+function h1QualityIndex(count: number | string | undefined): number {
const c = Number(count);
if (c === 0 || Number.isNaN(c)) return 0;
if (c === 1) return 1;
return 2;
}
-function oneHotDoughnut(labels, index, colors) {
+function oneHotDoughnut(labels: string[], index: number, colors: string[]) {
const data = labels.map((_, i) => (i === index ? 1 : 0));
const backgroundColor = labels.map((_, i) => (i === index ? colors[i] : `${colors[i]}33`));
return { labels, datasets: [{ data, backgroundColor, borderColor: 'rgba(15,23,42,0.8)', borderWidth: 2 }] };
}
-function wcBucketLabel(wc, buckets) {
+function wcBucketLabel(wc: number, buckets: string[]): string {
const w = Number(wc) || 0;
const b = buckets;
if (!b?.length) return '';
@@ -222,7 +224,7 @@ function wcBucketLabel(wc, buckets) {
return b[5];
}
-function readingBandLabel(rl, bands) {
+function readingBandLabel(rl: number, bands: string[]): string {
const r = Number(rl) || 0;
const b = bands;
if (!b?.length) return '';
@@ -232,7 +234,7 @@ function readingBandLabel(rl, bands) {
return b[3];
}
-function contentRatioBandLabel(pct, lc) {
+function contentRatioBandLabel(pct: number, lc: Record): string {
const p = Number(pct) || 0;
if (p <= 10) return lc.ratioLt10;
if (p <= 20) return lc.ratio1020;
@@ -240,7 +242,13 @@ function contentRatioBandLabel(pct, lc) {
return lc.ratioGt40;
}
-function SectionHeader({ icon, title, description }) {
+interface SectionHeaderProps {
+ icon: ComponentType<{ className?: string }>;
+ title: string;
+ description?: string;
+}
+
+function SectionHeader({ icon, title, description }: SectionHeaderProps) {
return (
@@ -254,14 +262,18 @@ function SectionHeader({ icon, title, description }) {
);
}
-export default function ContentTab({ link }) {
+export interface ContentTabProps {
+ link: LinkDetail;
+}
+
+export default function ContentTab({ link }: ContentTabProps) {
const lc = strings.components.linkTabs.content;
const vca = strings.views.contentAnalytics;
const vo = strings.views.overview;
const sj = strings.common;
const lo = strings.components.linkTabs.overview;
const { data } = useReport();
- const [kwHover, setKwHover] = useState(null);
+ const [kwHover, setKwHover] = useState (null);
const wc = link.word_count || 0;
const rl = link.reading_level || 0;
@@ -270,11 +282,11 @@ export default function ContentTab({ link }) {
const keywords = useMemo(() => parseKeywords(link.top_keywords), [link.top_keywords]);
const ratioPct = Math.min(100, Math.max(0, Number(link.content_html_ratio) || 0));
- const ca = data?.content_analytics || {};
- const wcStats = ca.word_count_stats || {};
- const wcDist = ca.word_count_distribution || {};
- const rlDist = ca.reading_level_distribution || {};
- const crDist = ca.content_ratio_distribution || {};
+ const ca: ContentAnalyticsData = data?.content_analytics ?? {};
+ const wcStats = ca.word_count_stats ?? {};
+ const wcDist = ca.word_count_distribution ?? {};
+ const rlDist = ca.reading_level_distribution ?? {};
+ const crDist = ca.content_ratio_distribution ?? {};
const wcLabels = Object.keys(wcDist);
const wcValues = Object.values(wcDist).map(Number);
@@ -304,8 +316,8 @@ export default function ContentTab({ link }) {
const hasTw = !!(link.twitter_card && String(link.twitter_card).trim());
const hasOgImg = !!(link.og_image && String(link.og_image).trim());
- const depthDist = data?.depth_distribution || {};
- const depthByDepth = depthDist.by_depth || {};
+ const depthDist = data?.depth_distribution ?? {};
+ const depthByDepth = depthDist.by_depth ?? {};
const depthLabels = Object.keys(depthByDepth).map((d) => format(lc.depthLabel, { n: d }));
const depthValues = Object.values(depthByDepth).map(Number);
const hasDepthData = depthLabels.length > 0;
@@ -559,7 +571,7 @@ export default function ContentTab({ link }) {
{link.content_excerpt && String(link.content_excerpt).trim() && (
-
+
{String(link.content_excerpt).trim()}
diff --git a/web/src/components/links/tabs/IssuesTab.jsx b/web/src/components/links/tabs/IssuesTab.tsx
similarity index 88%
rename from web/src/components/links/tabs/IssuesTab.jsx
rename to web/src/components/links/tabs/IssuesTab.tsx
index bbf3de33..713002e0 100644
--- a/web/src/components/links/tabs/IssuesTab.jsx
+++ b/web/src/components/links/tabs/IssuesTab.tsx
@@ -1,6 +1,8 @@
import { useState, useMemo } from 'react';
import { Bar } from 'react-chartjs-2';
+import type { TooltipItem } from 'chart.js';
import { Gauge, ChevronDown, ChevronUp, ChevronRight } from 'lucide-react';
+import type { InspectorDetails, InspectorIssueRow, LinkLighthouseData, LighthouseAuditRef } from '@/types/report';
import { strings, format } from '../../../lib/strings';
import { SELECT_CLASS, SEO_ISSUE_RECOMMENDATIONS, severityBg } from '../../../utils/linkUtils';
import { formatLhMetric } from '../../../utils/linkUtils';
@@ -9,16 +11,21 @@ import { registerChartJsBase, barOptionsHorizontal } from '../../../utils/chartJ
registerChartJsBase();
-export default function IssuesTab({ lhData, inspectorDetails }) {
+export interface IssuesTabProps {
+ lhData?: LinkLighthouseData | null;
+ inspectorDetails: InspectorDetails | null;
+}
+
+export default function IssuesTab({ lhData, inspectorDetails }: IssuesTabProps) {
const ci = strings.components.inspectorTabs;
const it = strings.components.linkTabs.issues;
const sj = strings.common;
- const [expandedIssue, setExpandedIssue] = useState(null);
+ const [expandedIssue, setExpandedIssue] = useState(null);
const [issueFilter, setIssueFilter] = useState('All');
- const allIssues = useMemo(() => {
+ const allIssues = useMemo((): InspectorIssueRow[] => {
if (!inspectorDetails) return [];
- const list = [];
+ const list: InspectorIssueRow[] = [];
inspectorDetails.broken.forEach((i) =>
list.push({ severity: 'Critical', message: format(ci.brokenMessage, { status: i.status }), type: 'broken' })
);
@@ -32,7 +39,12 @@ export default function IssuesTab({ lhData, inspectorDetails }) {
})
);
inspectorDetails.seoIssues.forEach((i) =>
- list.push({ severity: 'High', message: i.message, type: 'seo', recommendation: SEO_ISSUE_RECOMMENDATIONS[i.type] })
+ list.push({
+ severity: 'High',
+ message: i.message || '',
+ type: 'seo',
+ recommendation: i.type ? SEO_ISSUE_RECOMMENDATIONS[i.type as keyof typeof SEO_ISSUE_RECOMMENDATIONS] : undefined,
+ })
);
inspectorDetails.contentFlags.forEach((i) =>
list.push({
@@ -45,7 +57,7 @@ export default function IssuesTab({ lhData, inspectorDetails }) {
inspectorDetails.categoryIssues.forEach((i) =>
list.push({
severity: i.priority || 'Medium',
- message: i.message,
+ message: i.message ?? '',
type: 'category',
category: i.category,
recommendation: i.recommendation,
@@ -54,7 +66,7 @@ export default function IssuesTab({ lhData, inspectorDetails }) {
inspectorDetails.securityFindings.forEach((i) =>
list.push({
severity: i.severity || 'Medium',
- message: i.message,
+ message: i.message ?? '',
type: 'security',
recommendation: i.recommendation,
})
@@ -82,7 +94,7 @@ export default function IssuesTab({ lhData, inspectorDetails }) {
...base.plugins,
tooltip: {
callbacks: {
- label: (ctx) => {
+ label: (ctx: TooltipItem<'bar'>) => {
const n = Number(ctx.raw);
return ` ${format(it.issueTooltip, { n: n.toLocaleString(), s: n !== 1 ? 's' : '' })}`;
},
@@ -127,7 +139,7 @@ export default function IssuesTab({ lhData, inspectorDetails }) {
<>
{it.lighthouseFailures}
- {topFailures.map((f, i) => (
+ {topFailures.map((f: LighthouseAuditRef, i: number) => (
{f.helpText || f.id}
@@ -207,11 +219,11 @@ export default function IssuesTab({ lhData, inspectorDetails }) {
)}
- {inspectorDetails?.recommendations?.length > 0 && (
+ {inspectorDetails && (inspectorDetails.recommendations?.length ?? 0) > 0 && (
{it.whatToImprove}
- {inspectorDetails.recommendations.map((rec, i) => (
+ {inspectorDetails.recommendations.map((rec: string, i: number) => (
0 ? (
+ (link.redirect_chain_length ?? 0) > 0 ? (
{link.redirect_chain_length}
) : (
'0'
diff --git a/web/src/components/links/tabs/PageAnalysisTab.jsx b/web/src/components/links/tabs/PageAnalysisTab.tsx
similarity index 88%
rename from web/src/components/links/tabs/PageAnalysisTab.jsx
rename to web/src/components/links/tabs/PageAnalysisTab.tsx
index c8d17fb4..a9aa4b24 100644
--- a/web/src/components/links/tabs/PageAnalysisTab.jsx
+++ b/web/src/components/links/tabs/PageAnalysisTab.tsx
@@ -1,6 +1,8 @@
import { useMemo, useState } from 'react';
import { Bar } from 'react-chartjs-2';
+import type { TooltipItem } from 'chart.js';
import { Gauge, ChevronDown, ChevronRight } from 'lucide-react';
+import type { LinkDetail, LinkLighthouseData, LighthouseAuditRef, NlpSignals, PageAnalysis, SimilarInternalRow } from '@/types/report';
import { useReport } from '../../../context/useReport';
import { formatLhMetric, parseKeywords, normaliseKw, severityBg } from '../../../utils/linkUtils';
import { palette, scoreBandColor } from '../../../utils/chartPalette';
@@ -11,26 +13,29 @@ import { strings, format } from '../../../lib/strings';
registerChartJsBase();
-/** @param {unknown} raw */
-function normalizeSimilarInternal(raw) {
+function normalizeSimilarInternal(raw: unknown): SimilarInternalRow[] {
if (!Array.isArray(raw)) return [];
return raw
- .map((item) => {
+ .map((item): SimilarInternalRow | null => {
if (typeof item === 'string') return { url: item, score: null };
- if (item && typeof item === 'object' && typeof item.url === 'string') {
- const sc = item.score;
+ if (item && typeof item === 'object' && typeof (item as Record ).url === 'string') {
+ const obj = item as Record;
+ const sc = obj.score;
return {
- url: item.url,
+ url: obj.url as string,
score: sc != null && sc !== '' ? Number(sc) : null,
};
}
return null;
})
- .filter(Boolean);
+ .filter((row): row is SimilarInternalRow => row != null);
}
-/** @param {Record|undefined} nlp */
-function NerBlock({ nlp }) {
+interface NerBlockProps {
+ nlp: NlpSignals | undefined;
+}
+
+function NerBlock({ nlp }: NerBlockProps) {
const p = strings.components.linkTabs.pageAnalysis;
if (!nlp || typeof nlp !== 'object') return null;
const count = nlp.entity_count;
@@ -63,7 +68,7 @@ function NerBlock({ nlp }) {
);
}
-function resolveResourceUrl(raw, pageUrl) {
+function resolveResourceUrl(raw: string | null | undefined, pageUrl: string): string | null {
let s = (raw || '').trim();
if (!s) return null;
if (s.startsWith('//')) s = `https:${s}`;
@@ -76,7 +81,12 @@ function resolveResourceUrl(raw, pageUrl) {
}
}
-function ImageUrlListItem({ rawUrl, pageUrl }) {
+interface ImageUrlListItemProps {
+ rawUrl: string;
+ pageUrl: string;
+}
+
+function ImageUrlListItem({ rawUrl, pageUrl }: ImageUrlListItemProps) {
const p = strings.components.linkTabs.pageAnalysis;
const [broken, setBroken] = useState(false);
const href = resolveResourceUrl(rawUrl, pageUrl);
@@ -110,7 +120,15 @@ function ImageUrlListItem({ rawUrl, pageUrl }) {
);
}
-function ResourceSection({ title, urls, defaultOpen = false, variant = 'links', pageUrl = '' }) {
+interface ResourceSectionProps {
+ title: string;
+ urls: string[] | undefined;
+ defaultOpen?: boolean;
+ variant?: 'links' | 'images';
+ pageUrl?: string;
+}
+
+function ResourceSection({ title, urls, defaultOpen = false, variant = 'links', pageUrl = '' }: ResourceSectionProps) {
const p = strings.components.linkTabs.pageAnalysis;
const [open, setOpen] = useState(defaultOpen);
const [showAll, setShowAll] = useState(false);
@@ -170,13 +188,17 @@ function ResourceSection({ title, urls, defaultOpen = false, variant = 'links',
);
}
-export default function PageAnalysisTab({ link }) {
+export interface PageAnalysisTabProps {
+ link: LinkDetail;
+}
+
+export default function PageAnalysisTab({ link }: PageAnalysisTabProps) {
const p = strings.components.linkTabs.pageAnalysis;
const sj = strings.common;
const lhLabels = strings.lighthouse.categoryLabels;
const { data } = useReport();
- const pa = link.page_analysis && typeof link.page_analysis === 'object' ? link.page_analysis : {};
- const lh = link.lighthouse || null;
+ const pa: PageAnalysis = link.page_analysis && typeof link.page_analysis === 'object' ? link.page_analysis : {};
+ const lh: LinkLighthouseData | null = link.lighthouse || null;
const nlpSignals = link.nlp_entities || pa?.signals?.nlp_entities;
const similarRows = useMemo(() => normalizeSimilarInternal(link.similar_internal), [link.similar_internal]);
@@ -195,8 +217,8 @@ export default function PageAnalysisTab({ link }) {
return audits.filter((a) => a?.score != null && a.score < 1);
}, [lh?.audits]);
- const reportAt = data?.report_generated_at || data?.crawl_run_created_at || null;
- const sslExp = data?.site_ssl_expires_at || null;
+ const reportAt = (data?.report_generated_at || data?.crawl_run_created_at || null) as string | null;
+ const sslExp = (data?.site_ssl_expires_at || null) as string | null;
const resourceChart = useMemo(() => {
const internalN = Number(pa.internal_link_count ?? link.internal_link_count) || 0;
@@ -225,7 +247,7 @@ export default function PageAnalysisTab({ link }) {
...base,
plugins: {
...base.plugins,
- tooltip: { callbacks: { label: (ctx) => ` ${Number(ctx.raw).toLocaleString()}` } },
+ tooltip: { callbacks: { label: (ctx: TooltipItem<'bar'>) => ` ${Number(ctx.raw).toLocaleString()}` } },
},
};
}, []);
@@ -233,9 +255,10 @@ export default function PageAnalysisTab({ link }) {
const lhCategoryChart = useMemo(() => {
if (!lh?.category_scores) return null;
const keys = ['performance', 'accessibility', 'best-practices', 'seo'];
- const labels = keys.map((k) => lhLabels[k] || k);
+ const categoryLabels = lhLabels as Record;
+ const labels = keys.map((k) => categoryLabels[k] || k);
const values = keys.map((k) => {
- const v = lh.category_scores[k];
+ const v = lh.category_scores?.[k];
return v != null ? Number(v) : 0;
});
const colors = values.map((v) => scoreBandColor(v));
@@ -317,9 +340,8 @@ export default function PageAnalysisTab({ link }) {
{(link.duplicate_group_id ||
similarRows.length > 0 ||
- link.ml_anomaly ||
link.detected_language ||
- link.keyphrases?.phrases?.length > 0 ||
+ link.keyphrases?.phrases?.length &&
pa?.signals?.language ||
(nlpSignals && (nlpSignals.entity_count != null || (nlpSignals.top_entity_labels && nlpSignals.top_entity_labels.length > 0)))) && (
@@ -338,32 +360,26 @@ export default function PageAnalysisTab({ link }) {
)}
- {link.keyphrases?.phrases?.length > 0 && (
+ {link.keyphrases?.phrases && link.keyphrases.phrases.length > 0 && (
{p.keyphrasesKeybert}
- {link.keyphrases.phrases.map((pair, i) => (
+ {link.keyphrases.phrases.map((pair: unknown, i: number) => {
+ const phrasePair = Array.isArray(pair) ? pair : [pair];
+ return (
-
- {pair[0]}
- {typeof pair[1] === 'number' && (
- ({pair[1].toFixed(2)})
+ {String(phrasePair[0])}
+ {typeof phrasePair[1] === 'number' && (
+ ({phrasePair[1].toFixed(2)})
)}
- ))}
+ );})}
)}
- {link.ml_anomaly && (
-
- {p.anomalyIsolation}
-
- {p.anomalyScorePrefix} {link.ml_anomaly.anomaly_score} — {(link.ml_anomaly.reasons || []).join(', ')}
-
-
- )}
{similarRows.length > 0 && (
@@ -419,9 +435,10 @@ export default function PageAnalysisTab({ link }) {
const cs = lh.category_scores || {};
const score = cs[cat] != null ? Number(cs[cat]) : null;
const color = score != null ? scoreBandColor(score) : 'rgb(71,85,105)';
+ const categoryLabels = lhLabels as Record ;
return (
- {lhLabels[cat] || cat.replace('-', ' ')}
+ {categoryLabels[cat] || cat.replace('-', ' ')}
{score != null ? score : sj.emDash}
);
@@ -441,7 +458,7 @@ export default function PageAnalysisTab({ link }) {
{(lh.top_failures || []).length > 0 && (
{p.lhRecommendations}
- {(lh.top_failures || []).map((f, i) => (
+ {(lh.top_failures || []).map((f: LighthouseAuditRef, i: number) => (
{f.id}
{f.helpText || f.title || f.id}
@@ -456,8 +473,8 @@ export default function PageAnalysisTab({ link }) {
{format(p.failingAuditsCaption, { count: failingLighthouseAudits.length })}
- {failingLighthouseAudits.map((a) => (
-
+ {failingLighthouseAudits.map((a: LighthouseAuditRef) => (
+
))}
@@ -543,11 +560,11 @@ export default function PageAnalysisTab({ link }) {
{p.resources}
- {p.resourceSections.map(({ key, label }) => (
+ {p.resourceSections.map(({ key, label }: { key: keyof PageAnalysis; label: string }) => (
diff --git a/web/src/components/links/tabs/SeoSocialTab.jsx b/web/src/components/links/tabs/SeoSocialTab.tsx
similarity index 94%
rename from web/src/components/links/tabs/SeoSocialTab.jsx
rename to web/src/components/links/tabs/SeoSocialTab.tsx
index 7f59e2bb..281290c3 100644
--- a/web/src/components/links/tabs/SeoSocialTab.jsx
+++ b/web/src/components/links/tabs/SeoSocialTab.tsx
@@ -1,11 +1,16 @@
import { useMemo } from 'react';
import { CheckCircle, XCircle } from 'lucide-react';
+import type { LinkDetail, TechStackEntry } from '@/types/report';
import { strings } from '../../../lib/strings';
import { parseTechStack } from '../../../utils/linkUtils';
import CopyBtn from '../CopyBtn';
import OGPreview from '../OGPreview';
-export default function SeoSocialTab({ link }) {
+export interface SeoSocialTabProps {
+ link: LinkDetail;
+}
+
+export default function SeoSocialTab({ link }: SeoSocialTabProps) {
const s = strings.components.linkTabs.seoSocial;
const sj = strings.common;
const techStack = useMemo(() => parseTechStack(link.tech_stack), [link.tech_stack]);
@@ -113,14 +118,16 @@ export default function SeoSocialTab({ link }) {
{s.detectedTech}
- {techStack.map((t, i) => (
+ {techStack.map((entry, i) => {
+ const t = entry as TechStackEntry | string;
+ return (
{typeof t === 'object' ? (t.name || t.tech || JSON.stringify(t)) : t}
- ))}
+ );})}
)}
diff --git a/web/src/components/links/tabs/TechnicalTab.jsx b/web/src/components/links/tabs/TechnicalTab.tsx
similarity index 88%
rename from web/src/components/links/tabs/TechnicalTab.jsx
rename to web/src/components/links/tabs/TechnicalTab.tsx
index a6654189..e62058a6 100644
--- a/web/src/components/links/tabs/TechnicalTab.jsx
+++ b/web/src/components/links/tabs/TechnicalTab.tsx
@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import { Bar, Doughnut } from 'react-chartjs-2';
+import type { TooltipItem } from 'chart.js';
import { Shield, Zap, Image } from 'lucide-react';
+import type { LinkDetail } from '@/types/report';
import { strings, format } from '../../../lib/strings';
import SecHeaderRow from '../SecHeaderRow';
import MiniBar from '../MiniBar';
@@ -8,13 +10,17 @@ import { registerChartJsBase, barOptionsHorizontal, doughnutOptionsBottomLegend
registerChartJsBase();
-function headerPresent(val) {
+function headerPresent(val: unknown): boolean {
if (val == null) return false;
if (typeof val === 'string') return val.trim().length > 0;
return Boolean(val);
}
-export default function TechnicalTab({ link }) {
+export interface TechnicalTabProps {
+ link: LinkDetail;
+}
+
+export default function TechnicalTab({ link }: TechnicalTabProps) {
const lt = strings.components.linkTabs.technical;
const SEC_HEADERS = lt.securityRows;
@@ -26,17 +32,17 @@ export default function TechnicalTab({ link }) {
{
label: lt.perfMixedContent,
value:
- link.mixed_content_count > 0
- ? format(lt.mixedItems, { n: link.mixed_content_count })
+ (link.mixed_content_count ?? 0) > 0
+ ? format(lt.mixedItems, { n: link.mixed_content_count ?? 0 })
: lt.mixedNone,
- warn: link.mixed_content_count > 0,
+ warn: (link.mixed_content_count ?? 0) > 0,
},
];
const imgTotal = link.images_total || 0;
const securityHeaderCounts = useMemo(() => {
- const present = SEC_HEADERS.filter((h) => headerPresent(link[h.field])).length;
+ const present = SEC_HEADERS.filter((h) => headerPresent(link[h.field as keyof LinkDetail])).length;
const missing = SEC_HEADERS.length - present;
return { present, missing };
}, [link, SEC_HEADERS]);
@@ -59,7 +65,7 @@ export default function TechnicalTab({ link }) {
...base.plugins,
tooltip: {
callbacks: {
- label: (ctx) => ` ${format(lt.tooltipItems, { n: Number(ctx.raw).toLocaleString() })}`,
+ label: (ctx: TooltipItem<'bar'>) => ` ${format(lt.tooltipItems, { n: Number(ctx.raw).toLocaleString() })}`,
},
},
},
@@ -115,7 +121,7 @@ export default function TechnicalTab({ link }) {
))}
@@ -156,13 +162,13 @@ export default function TechnicalTab({ link }) {
value={link.images_without_alt || 0}
total={Math.max(imgTotal, 1)}
label={lt.missingAlt}
- color={link.images_without_alt > 0 ? 'bg-red-500' : 'bg-green-500'}
+ color={(link.images_without_alt ?? 0) > 0 ? 'bg-red-500' : 'bg-green-500'}
/>
0 ? 'bg-yellow-500' : 'bg-green-500'}
+ color={(link.img_without_lazy ?? 0) > 0 ? 'bg-yellow-500' : 'bg-green-500'}
/>
{lt.ariaElements}
diff --git a/web/src/components/pipeline/ConfigField.tsx b/web/src/components/pipeline/ConfigField.tsx
new file mode 100644
index 00000000..51aee54f
--- /dev/null
+++ b/web/src/components/pipeline/ConfigField.tsx
@@ -0,0 +1,386 @@
+export type ConfigFieldDef = {
+ key: string;
+ label: string;
+ type: string;
+ defaultValue?: string | boolean | number;
+ help?: string;
+ placeholder?: string;
+ options?: { value: string; label: string }[];
+ /** Grid columns in the settings panel (1 = half width, 2 = full width). */
+ span?: 1 | 2;
+ /** Optional suffix beside numeric card inputs (e.g. pages, rows). */
+ unit?: string;
+ /** When true, value must be non-empty before save/run. */
+ required?: boolean;
+};
+
+export interface ConfigFieldProps {
+ field: ConfigFieldDef;
+ value: string | boolean | undefined;
+ disabled?: boolean;
+ onChange: (v: string | boolean) => void;
+}
+
+function fieldSpan(f: ConfigFieldDef): 1 | 2 {
+ if (f.span != null) return f.span;
+ if (f.type === 'url' || f.type === 'textarea' || f.type === 'bool' || f.type === 'tristate' || f.type === 'secret') {
+ return 2;
+ }
+ if (f.type === 'singleselect' || f.type === 'multiselect' || f.type === 'select') return 2;
+ if ((f.type === 'text' || f.type === 'number' || f.type === 'float') && f.help) return 2;
+ return 1;
+}
+
+function wrapClass(span: 1 | 2) {
+ return span === 2 ? 'min-w-0 sm:col-span-2' : 'min-w-0';
+}
+
+const inputClass =
+ 'w-full rounded-lg border border-default bg-brand-900 px-3 py-2 text-sm text-foreground focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/20';
+
+/** Single config row for any field type. */
+export default function ConfigField({ field: f, value, disabled, onChange }: ConfigFieldProps) {
+ const id = `pipe-cfg-${f.key}`;
+ const span = fieldSpan(f);
+ const outerClass = wrapClass(span);
+
+ const labelBlock = (
+
+
+ {f.help ? {f.help} : null}
+
+ );
+
+ const helpBelow = f.help ? (
+ {f.help}
+ ) : null;
+
+ if (f.type === 'url') {
+ const strVal = value == null ? '' : String(value);
+ return (
+
+ {labelBlock}
+ onChange(e.target.value)}
+ className={inputClass}
+ />
+
+ );
+ }
+
+ if (f.type === 'select') {
+ const strVal = value == null ? String(f.defaultValue ?? '') : String(value);
+ return (
+
+
+
+ {helpBelow}
+
+ );
+ }
+
+ if (f.type === 'singleselect') {
+ const strVal = value == null ? String(f.defaultValue ?? '') : String(value);
+ const options = f.options || [];
+ const optionGridClass = span === 1 ? 'grid gap-2 grid-cols-1' : 'grid gap-2 sm:grid-cols-2';
+
+ return (
+
+ {f.label}
+
+ {options.map((opt) => {
+ const optId = `${id}-${opt.value}`;
+ return (
+
+ );
+ })}
+
+ {helpBelow}
+
+ );
+ }
+
+ if (f.type === 'multiselect') {
+ const raw = value == null ? String(f.defaultValue ?? '') : String(value);
+ const selected = new Set(
+ raw
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean),
+ );
+ const options = f.options || [];
+
+ const toggle = (optValue: string, checked: boolean) => {
+ const next = new Set(selected);
+ if (checked) next.add(optValue);
+ else next.delete(optValue);
+ const ordered = options.filter((opt) => next.has(opt.value)).map((opt) => opt.value);
+ onChange(ordered.join(','));
+ };
+
+ return (
+
+ {f.label}
+
+ {options.map((opt) => {
+ const optId = `${id}-${opt.value}`;
+ return (
+
+ );
+ })}
+
+ {helpBelow}
+
+ );
+ }
+
+ if (f.type === 'secret') {
+ const strVal = value == null ? '' : String(value);
+ const isMasked = strVal.startsWith('••••');
+ const displayValue = isMasked ? '' : strVal;
+ const placeholder = isMasked
+ ? 'Paste a new key to replace the saved one'
+ : 'Paste API key (or use env vars — see below)';
+
+ return (
+
+
+ {labelBlock}
+ onChange(e.target.value)}
+ className={`${inputClass} font-mono`}
+ />
+ {isMasked ? (
+
+
+ Key saved ({strVal}). Leave blank to keep it.
+
+ ) : null}
+
+
+ );
+ }
+
+ if (f.type === 'bool') {
+ const checked = value === true;
+ if (f.help) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+ }
+
+ if (f.type === 'tristate') {
+ const strVal = value == null ? 'auto' : String(value);
+ const options = f.options ?? [
+ { value: 'auto', label: 'Auto' },
+ { value: 'true', label: 'Yes' },
+ { value: 'false', label: 'No' },
+ ];
+ const optionGridClass = span === 1 ? 'grid gap-2 grid-cols-1' : 'grid gap-2 sm:grid-cols-3';
+
+ return (
+
+ {f.help ? (
+ labelBlock
+ ) : (
+ {f.label}
+ )}
+
+ {options.map((opt) => {
+ const optId = `${id}-${opt.value}`;
+ return (
+
+ );
+ })}
+
+
+ );
+ }
+
+ if (f.type === 'textarea') {
+ const strVal = value == null ? '' : String(value);
+ return (
+
+ {labelBlock}
+
+ );
+ }
+
+ const strVal = value == null ? '' : String(value);
+ const isNumeric = f.type === 'number' || f.type === 'float';
+
+ if (isNumeric && f.help) {
+ return (
+
+
+
+
+
+ {f.help}
+
+
+ onChange(e.target.value)}
+ aria-label={f.label}
+ className={`${inputClass} w-full min-w-[5.5rem] max-w-[7rem] font-mono tabular-nums sm:text-right`}
+ />
+ {f.unit ? (
+ {f.unit}
+ ) : null}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {f.help ? (
+ labelBlock
+ ) : (
+
+ )}
+ onChange(e.target.value)}
+ className={`${inputClass}${isNumeric ? ' font-mono' : ''}`}
+ />
+
+ );
+}
diff --git a/web/src/components/pipeline/CrawlAuthorizeCheckbox.tsx b/web/src/components/pipeline/CrawlAuthorizeCheckbox.tsx
new file mode 100644
index 00000000..3edb4769
--- /dev/null
+++ b/web/src/components/pipeline/CrawlAuthorizeCheckbox.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { strings } from '@/lib/strings';
+
+const c = strings.components.crawlAuthorize;
+
+export default function CrawlAuthorizeCheckbox({
+ checked,
+ onChange,
+}: {
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineLogViewer.tsx b/web/src/components/pipeline/PipelineLogViewer.tsx
new file mode 100644
index 00000000..f3897934
--- /dev/null
+++ b/web/src/components/pipeline/PipelineLogViewer.tsx
@@ -0,0 +1,431 @@
+'use client';
+
+import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
+import {
+ AlertTriangle,
+ Check,
+ ChevronDown,
+ Copy,
+ Download,
+ Maximize2,
+ Minimize2,
+ Search,
+ X,
+} from 'lucide-react';
+import type { PipelineJobStatus } from '@/types/api';
+import { strings } from '@/lib/strings';
+import {
+ filterPipelineLogLines,
+ getPipelineLogStats,
+ groupPipelineLogLines,
+ parsePipelineLog,
+ PHASE_CHIP_CLASS,
+ PIPELINE_LOG_LINE_CLASS,
+ type PipelineLogLine,
+} from '@/lib/formatPipelineLog';
+
+export interface PipelineLogViewerProps {
+ log: string;
+ autoScroll?: boolean;
+ status?: PipelineJobStatus | '';
+ className?: string;
+}
+
+function highlightText(text: string, query: string) {
+ if (!query.trim()) return text;
+ const q = query.trim();
+ const lower = text.toLowerCase();
+ const idx = lower.indexOf(q.toLowerCase());
+ if (idx < 0) return text;
+ return (
+ <>
+ {text.slice(0, idx)}
+ {text.slice(idx, idx + q.length)}
+ {text.slice(idx + q.length)}
+ >
+ );
+}
+
+function ProgressLine({ line, query }: { line: PipelineLogLine; query: string }) {
+ const p = line.progress;
+ if (!p) {
+ return (
+ {highlightText(line.text, query)}
+ );
+ }
+ return (
+
+
+ Crawl progress
+
+ {p.current}/{p.total} ({p.percent}%)
+
+
+
+
+ );
+}
+
+function LogLine({ line, query }: { line: PipelineLogLine; query: string }) {
+ if (line.kind === 'noise') {
+ return (
+
+ {highlightText(line.text, query)}
+
+ );
+ }
+
+ if (line.kind === 'progress') {
+ return (
+
+ );
+ }
+
+ const colorClass = PIPELINE_LOG_LINE_CLASS[line.kind];
+ const isErrorBlock = line.kind === 'error' || line.kind === 'traceback';
+
+ if (line.kind === 'section') {
+ const m = line.text.match(/^(\[[^\]]+\])(.*)$/);
+ if (m) {
+ return (
+
+ {highlightText(m[1], query)}
+ {highlightText(m[2], query)}
+
+ );
+ }
+ }
+
+ return (
+
+ {highlightText(line.text, query)}
+
+ );
+}
+
+function LogGroupBlock({
+ label,
+ phase,
+ done,
+ lines,
+ query,
+ defaultOpen,
+ firstErrorId,
+ errorRef,
+}: {
+ label: string;
+ phase: PipelineLogLine['phase'];
+ done: boolean;
+ lines: PipelineLogLine[];
+ query: string;
+ defaultOpen: boolean;
+ firstErrorId: number | null;
+ errorRef: RefObject ;
+}) {
+ const [open, setOpen] = useState(defaultOpen);
+ const chip = PHASE_CHIP_CLASS[phase];
+
+ return (
+
+
+ {open ? (
+
+ {lines.map((line) => (
+
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export default function PipelineLogViewer({
+ log,
+ autoScroll = true,
+ status = '',
+ className = '',
+}: PipelineLogViewerProps) {
+ const scrollRef = useRef(null);
+ const errorRef = useRef(null);
+ const [copied, setCopied] = useState(false);
+ const [expanded, setExpanded] = useState(false);
+ const [query, setQuery] = useState('');
+ const [hideNoise, setHideNoise] = useState(false);
+ const [errorsOnly, setErrorsOnly] = useState(false);
+ const [grouped, setGrouped] = useState(true);
+
+ const allLines = useMemo(() => parsePipelineLog(log), [log]);
+ const visibleLines = useMemo(
+ () => filterPipelineLogLines(allLines, { query, hideNoise, errorsOnly }),
+ [allLines, query, hideNoise, errorsOnly],
+ );
+ const groups = useMemo(() => groupPipelineLogLines(visibleLines), [visibleLines]);
+ const stats = useMemo(() => getPipelineLogStats(allLines), [allLines]);
+ const firstErrorId = useMemo(
+ () => visibleLines.find((l) => l.kind === 'error' || l.kind === 'traceback')?.id ?? null,
+ [visibleLines],
+ );
+ const isRunning = status === 'running' || status === 'starting';
+
+ useEffect(() => {
+ if (!autoScroll || !scrollRef.current || errorsOnly) return;
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }, [log, autoScroll, visibleLines.length, errorsOnly]);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(log);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleDownload = () => {
+ const blob = new Blob([log], { type: 'text/plain;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `pipeline-log-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.txt`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const jumpToError = () => {
+ errorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ };
+
+ const shellClass = expanded
+ ? 'fixed inset-4 z-50 flex flex-col rounded-xl border border-default bg-brand-900 shadow-2xl'
+ : 'relative';
+
+ const logBody = (
+ <>
+
+
+
+
+
+ {visibleLines.length}/{allLines.length} lines
+
+ {stats.errors > 0 ? (
+ {stats.errors} error line{stats.errors === 1 ? '' : 's'}
+ ) : null}
+ {stats.warnings > 0 ? (
+ {stats.warnings} warning{stats.warnings === 1 ? '' : 's'}
+ ) : null}
+ {stats.noise > 0 ? (
+
+ {stats.noise} shutdown line{stats.noise === 1 ? '' : 's'}
+ {hideNoise ? ' hidden' : ''}
+
+ ) : null}
+ {isRunning ? (
+
+
+ Live
+
+ ) : null}
+
+
+ {stats.errors > 0 ? (
+
+ ) : null}
+
+
+
+
+
+
+ {stats.lastProgress?.progress && isRunning ? (
+
+
+ Latest progress
+
+ {stats.lastProgress.progress.current}/{stats.lastProgress.progress.total} (
+ {stats.lastProgress.progress.percent}%)
+
+
+
+
+ ) : null}
+
+
+ {visibleLines.length === 0 ? (
+
+ {allLines.length === 0 ? 'No output yet.' : 'No lines match your filters.'}
+
+ ) : grouped ? (
+
+ {groups.map((group, i) => (
+
+ ))}
+
+ ) : (
+
+ {visibleLines.map((line) => (
+
+
+
+ ))}
+
+ )}
+
+ >
+ );
+
+ if (expanded) {
+ return (
+ <>
+ setExpanded(false)} />
+
+
+ {strings.pipelineRunner.outputTitle}
+
+
+ {logBody}
+
+ >
+ );
+ }
+
+ return {logBody} ;
+}
diff --git a/web/src/components/pipeline/PipelineRunPanel.tsx b/web/src/components/pipeline/PipelineRunPanel.tsx
new file mode 100644
index 00000000..a652a31c
--- /dev/null
+++ b/web/src/components/pipeline/PipelineRunPanel.tsx
@@ -0,0 +1,405 @@
+'use client';
+
+import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
+import {
+ ArrowLeft,
+ ArrowRight,
+ Check,
+ ChevronDown,
+ ChevronUp,
+ Globe,
+ Loader2,
+ Play,
+ Terminal,
+} from 'lucide-react';
+import { strings } from '@/lib/strings';
+import Button from '@/components/Button';
+import Card from '@/components/Card';
+import { usePipeline } from '@/context/PipelineContext';
+import {
+ PipelineStatusBadge,
+ PRESET_COPY,
+ PresetIcon,
+} from './pipelineUi';
+import { PIPELINE_PRESETS } from './pipelinePresets';
+import PipelineWizardProgress, { type WizardStep } from './PipelineWizardProgress';
+import PipelineLogViewer from './PipelineLogViewer';
+import CrawlAuthorizeCheckbox from './CrawlAuthorizeCheckbox';
+
+const s = strings.pipelineRunner;
+const crawlPresets = s.crawlPresets as Record ;
+
+function isValidUrl(value: string): boolean {
+ const trimmed = value.trim();
+ if (!trimmed) return false;
+ try {
+ const parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
+ return Boolean(parsed.hostname);
+ } catch {
+ return false;
+ }
+}
+
+function normalizeUrl(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) return '';
+ if (trimmed.includes('://')) return trimmed;
+ return `https://${trimmed}`;
+}
+
+export default function PipelineRunPanel() {
+ const {
+ busy,
+ loading,
+ status,
+ log,
+ startUrl,
+ presetId,
+ handleStartUrlChange,
+ handlePresetChange,
+ setField,
+ run,
+ continueInBackground,
+ } = usePipeline();
+
+ const [crawlAuthorized, setCrawlAuthorized] = useState(false);
+
+ const urlInputRef = useRef(null);
+ const [step, setStep] = useState(1);
+ const [maxStep, setMaxStep] = useState(1);
+ const [outputOpen, setOutputOpen] = useState(true);
+
+ useEffect(() => {
+ if (step === 1) {
+ urlInputRef.current?.focus();
+ }
+ }, [step]);
+
+ useEffect(() => {
+ if (busy || status === 'running' || status === 'starting') {
+ setStep(3);
+ setMaxStep(3);
+ }
+ }, [busy, status]);
+
+ useEffect(() => {
+ if (status === 'error') {
+ setOutputOpen(true);
+ } else if (status === 'running' || status === 'starting') {
+ setOutputOpen(true);
+ }
+ }, [status, log]);
+
+ const disabled = busy || loading;
+ const urlValid = isValidUrl(startUrl);
+ const presetCopy = PRESET_COPY[presetId];
+ const crawlOnlyNote =
+ presetId === 'crawl-only' ? strings.reportSelector.crawlOnlyNote : null;
+ const showProgress = busy || Boolean(status) || Boolean(log);
+
+ const goToStep = (next: WizardStep) => {
+ setStep(next);
+ setMaxStep((prev) => (next > prev ? next : prev));
+ };
+
+ const handleContinueFromUrl = () => {
+ if (!urlValid) return;
+ handleStartUrlChange(normalizeUrl(startUrl));
+ goToStep(2);
+ };
+
+ const handleUrlKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && urlValid && !disabled) {
+ handleContinueFromUrl();
+ }
+ };
+
+ return (
+
+
+ !disabled && goToStep(target)}
+ />
+
+
+ {step > 1 ? (
+
+ {step >= 2 ? (
+
+
+
+ {normalizeUrl(startUrl)}
+
+ {step > 1 && !disabled ? (
+
+ ) : null}
+
+ ) : null}
+ {step >= 3 ? (
+
+
+ {!disabled ? (
+
+ ) : null}
+
+ ) : null}
+
+ ) : null}
+
+ {step === 1 ? (
+
+
+
+
+
+
+ {s.startUrlLabel}
+ {s.wizardUrlHint}
+
+
+ handleStartUrlChange(e.target.value)}
+ onKeyDown={handleUrlKeyDown}
+ disabled={disabled}
+ placeholder={s.startUrlPlaceholder}
+ className="mt-4 w-full rounded-lg border border-default bg-brand-900 px-3 py-3 text-sm text-foreground transition focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
+ />
+
+ Crawl size preset
+ {(['small', 'medium', 'large'] as const).map((key) => {
+ const preset = crawlPresets[key];
+ if (!preset) return null;
+ return (
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ {step === 2 ? (
+
+
+
+ {s.presetsLabel}
+
+ {s.wizardWorkflowHint}
+ {loading ? (
+
+
+ {s.loadingSettings}
+
+ ) : null}
+
+
+ {PIPELINE_PRESETS.map((preset) => {
+ const copy = PRESET_COPY[preset.id];
+ const selected = presetId === preset.id;
+ return (
+ handlePresetChange(preset.id)}
+ className={`relative cursor-pointer transition-all ${
+ selected
+ ? 'border-blue-500/70 bg-blue-500/5 ring-2 ring-blue-500/20'
+ : 'hover:border-muted-foreground/30 hover:bg-brand-800/80'
+ } ${disabled ? 'pointer-events-none opacity-50' : ''}`}
+ >
+
+
+
+ {copy.label}
+ {copy.description}
+
+ {selected ? (
+
+
+
+ ) : null}
+
+
+ );
+ })}
+
+ {crawlOnlyNote ? (
+
+ {crawlOnlyNote}
+
+ ) : null}
+
+
+
+
+
+ ) : null}
+
+ {step === 3 ? (
+
+
+ {s.wizardReviewTitle}
+ {s.wizardReviewHint}
+
+
+
+ -
+ {s.startUrlLabel}
+
+ - {normalizeUrl(startUrl)}
+
+
+ -
+ {s.presetsLabel}
+
+ -
+
+
+ {presetCopy.label}
+ {presetCopy.description}
+
+
+
+
+
+
+
+
+
+
+
+ {!busy ? (
+
+ ) : null}
+
+
+ {busy ? (
+
+ ) : null}
+
+
+
+
+
+ {showProgress ? (
+
+
+
+
+ {s.outputLabel}
+
+
+
+
+ {log ? (
+
+
+ {outputOpen ? (
+
+ ) : null}
+
+ ) : status === 'error' ? (
+
+ No log output was returned. Open the browser developer console for the full error
+ (filter by {strings.pipelineRunner.consoleFilterHint}).
+
+ ) : busy ? (
+
+
+ Waiting for output…
+
+ ) : null}
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineRunnerFab.tsx b/web/src/components/pipeline/PipelineRunnerFab.tsx
new file mode 100644
index 00000000..42efc494
--- /dev/null
+++ b/web/src/components/pipeline/PipelineRunnerFab.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { Loader2, Maximize2, Terminal } from 'lucide-react';
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { strings } from '@/lib/strings';
+import { usePipeline } from '@/context/PipelineContext';
+import { storePipelineReturnPath } from '@/lib/pipelineReturn';
+
+const s = strings.pipelineRunner;
+
+/**
+ * Floating entry point + background job dock (hidden on /pipeline page).
+ */
+export default function PipelineRunnerFab() {
+ const pathname = usePathname();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const { busy, status, log, backgroundMode, openPipelinePage } = usePipeline();
+
+ const onPipelinePage = pathname === '/pipeline' || pathname.startsWith('/pipeline/');
+ const showDock = backgroundMode && (busy || Boolean(status) || Boolean(log));
+
+ const goToPipeline = () => {
+ const q = searchParams.toString();
+ const current = q ? `${pathname}?${q}` : pathname;
+ if (!onPipelinePage) {
+ storePipelineReturnPath(current);
+ }
+ openPipelinePage('run');
+ };
+
+ if (onPipelinePage) {
+ return null;
+ }
+
+ return (
+
+ {showDock ? (
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ {s.dockTitle}
+
+ {busy
+ ? s.dockRunning
+ : status === 'error'
+ ? s.dockFailed
+ : status
+ ? `${s.statusLabel}: ${status}`
+ : log
+ ? s.dockFailed
+ : 'Idle'}
+
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineSettingsPanel.tsx b/web/src/components/pipeline/PipelineSettingsPanel.tsx
new file mode 100644
index 00000000..a4c2a7cd
--- /dev/null
+++ b/web/src/components/pipeline/PipelineSettingsPanel.tsx
@@ -0,0 +1,367 @@
+'use client';
+
+import { useEffect, useMemo, useState, type ReactNode } from 'react';
+import { Loader2, Save, X } from 'lucide-react';
+import { strings, format } from '@/lib/strings';
+import type { IntegrationToast } from '@/types/api';
+import { PIPELINE_CONFIG_SECTIONS } from '@/lib/pipelineConfigSchema';
+import { LLM_CONFIG_SECTIONS } from '@/lib/llmConfigSchema';
+import { usePipeline } from '@/context/PipelineContext';
+import Button from '@/components/Button';
+import GoogleIntegrationsPanel from '@/components/GoogleIntegrationsPanel';
+import ConfigField from './ConfigField';
+import PipelineSettingsSectionTabs from './PipelineSettingsSectionTabs';
+import {
+ PIPELINE_SETTINGS_GROUPS,
+ type PipelineSettingsGroupId,
+} from './pipelineSettingsGroups';
+
+const s = strings.pipelineRunner;
+
+type ConfigSection = (typeof PIPELINE_CONFIG_SECTIONS)[number];
+type LlmSection = (typeof LLM_CONFIG_SECTIONS)[number];
+
+export interface PipelineSettingsPanelProps {
+ activeGroup: PipelineSettingsGroupId;
+ googleIntegrationsToast?: IntegrationToast | null;
+ onSaved?: () => void;
+}
+
+function ConfigSectionFields({
+ section,
+ values,
+ disabled,
+ onChange,
+}: {
+ section: ConfigSection | LlmSection;
+ values: Record;
+ disabled: boolean;
+ onChange: (key: string, value: string | boolean) => void;
+}) {
+ return (
+
+ {section.fields.map((f) => (
+ onChange(f.key, v)}
+ />
+ ))}
+
+ );
+}
+
+export function PipelineSettingsSaveBar({ onSaved }: { onSaved?: () => void }) {
+ const { loading, saving, saveMsg, busy, saveSettings } = usePipeline();
+
+ const handleSave = async () => {
+ const ok = await saveSettings();
+ if (ok) onSaved?.();
+ };
+
+ const saveFailed = saveMsg.includes('Save failed') || saveMsg.includes('failed');
+
+ return (
+
+
+ {saveMsg ? (
+
+ {saveMsg}
+
+ ) : (
+ {s.settingsSubtitle}
+ )}
+
+
+
+ );
+}
+
+export default function PipelineSettingsPanel({
+ activeGroup,
+ googleIntegrationsToast,
+}: PipelineSettingsPanelProps) {
+ const {
+ loading,
+ busy,
+ configState,
+ llmConfigState,
+ unknownKeys,
+ configSource,
+ legacyBannerDismissed,
+ loadError,
+ pythonExe,
+ repoRoot,
+ customCommand,
+ setField,
+ setLlmField,
+ setPythonExe,
+ setRepoRoot,
+ setCustomCommand,
+ resetConfig,
+ dismissLegacyBanner,
+ } = usePipeline();
+
+ const group = PIPELINE_SETTINGS_GROUPS.find((g) => g.id === activeGroup);
+ const showLegacyBanner = configSource === 'legacy' && !legacyBannerDismissed && activeGroup === 'crawl-report';
+
+ const sectionPanels = useMemo(() => {
+ if (!group) return [];
+
+ const panels: { id: string; label: string; content: ReactNode }[] = [];
+
+ if (group.id === 'google') {
+ panels.push({
+ id: 'integrations',
+ label: s.settingsTabIntegrations,
+ content: ,
+ });
+ }
+
+ for (const sectionId of group.sectionIds) {
+ const section = PIPELINE_CONFIG_SECTIONS.find((sec) => sec.id === sectionId);
+ if (!section) continue;
+ panels.push({
+ id: section.id,
+ label: section.label,
+ content: (
+ setField(key, value)}
+ />
+ ),
+ });
+ }
+
+ if (group.includesLlm) {
+ for (const section of LLM_CONFIG_SECTIONS) {
+ panels.push({
+ id: section.id,
+ label: section.label,
+ content: (
+ setLlmField(key, value)}
+ />
+ ),
+ });
+ }
+ }
+
+ if (group.id === 'advanced') {
+ panels.push({
+ id: 'runner',
+ label: s.settingsTabRunner,
+ content: (
+
+
+
+ setCustomCommand(e.target.value)}
+ disabled={busy}
+ placeholder="e.g. warnings, enrich, plot"
+ className="w-full rounded-lg border border-default bg-brand-900 px-3 py-2 font-mono text-sm text-foreground focus:border-blue-500/50 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
+ />
+ {s.customCommandHelp}
+
+
+ {unknownKeys.length > 0 ? (
+
+ {s.unknownKeysHelp}
+
+ {unknownKeys.map(({ key, value }) => (
+
+ {key}
+ {' = '}
+ {value}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+ ),
+ });
+ }
+
+ return panels;
+ }, [
+ group,
+ busy,
+ configState,
+ llmConfigState,
+ googleIntegrationsToast,
+ customCommand,
+ pythonExe,
+ repoRoot,
+ unknownKeys,
+ setField,
+ setLlmField,
+ setCustomCommand,
+ setPythonExe,
+ setRepoRoot,
+ resetConfig,
+ ]);
+
+ const useSectionTabs = sectionPanels.length > 1;
+ const [activeSectionTab, setActiveSectionTab] = useState(sectionPanels[0]?.id ?? '');
+
+ useEffect(() => {
+ setActiveSectionTab(sectionPanels[0]?.id ?? '');
+ }, [activeGroup]);
+
+ useEffect(() => {
+ setActiveSectionTab((current) =>
+ sectionPanels.some((p) => p.id === current) ? current : (sectionPanels[0]?.id ?? ''),
+ );
+ }, [sectionPanels]);
+
+ const activePanel = sectionPanels.find((p) => p.id === activeSectionTab) ?? sectionPanels[0];
+
+ if (!group) {
+ return null;
+ }
+
+ const settingsCardClass = 'rounded-xl border border-default bg-brand-800/60 p-5 sm:p-6';
+
+ return (
+
+ {showLegacyBanner ? (
+
+ {s.legacyBanner}
+
+
+ ) : null}
+
+ {loadError ? (
+
+
+ {format(s.loadError, { message: loadError })}
+
+
+ ) : null}
+
+ {loading ? (
+
+
+ {s.loadingSettings}
+
+ ) : (
+
+ {group.id === 'content-ai' ? (
+
+ {s.contentAiHint}
+
+ ) : null}
+
+ {group.id === 'google' ? (
+
+ {s.googleGroupHint}
+
+ ) : null}
+
+ {useSectionTabs ? (
+ <>
+ ({ id: p.id, label: p.label }))}
+ activeTab={activeSectionTab}
+ onChange={setActiveSectionTab}
+ ariaLabel={s.settingsSectionTabsLabel}
+ />
+ {activePanel ? (
+
+ {activePanel.content}
+
+ ) : null}
+ >
+ ) : (
+
+ {activePanel?.content}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineSettingsSectionTabs.tsx b/web/src/components/pipeline/PipelineSettingsSectionTabs.tsx
new file mode 100644
index 00000000..f15290cb
--- /dev/null
+++ b/web/src/components/pipeline/PipelineSettingsSectionTabs.tsx
@@ -0,0 +1,43 @@
+'use client';
+
+interface PipelineSettingsSectionTabsProps {
+ tabs: { id: string; label: string }[];
+ activeTab: string;
+ onChange: (id: string) => void;
+ ariaLabel: string;
+}
+
+export default function PipelineSettingsSectionTabs({
+ tabs,
+ activeTab,
+ onChange,
+ ariaLabel,
+}: PipelineSettingsSectionTabsProps) {
+ return (
+
+
+ {tabs.map((tab) => {
+ const isActive = activeTab === tab.id;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/components/pipeline/PipelineShell.tsx b/web/src/components/pipeline/PipelineShell.tsx
new file mode 100644
index 00000000..67d93b65
--- /dev/null
+++ b/web/src/components/pipeline/PipelineShell.tsx
@@ -0,0 +1,216 @@
+'use client';
+
+import { useState, type ReactNode } from 'react';
+import Link from 'next/link';
+import { ArrowLeft, Menu, Play, X } from 'lucide-react';
+import AppLogo from '@/components/AppLogo';
+import ThemeToggle from '@/components/ThemeToggle';
+import { strings } from '@/lib/strings';
+import { readPipelineReturnPath } from '@/lib/pipelineReturn';
+import {
+ PIPELINE_SETTINGS_GROUPS,
+ type PipelineSettingsGroupId,
+} from '@/components/pipeline/pipelineSettingsGroups';
+import { SETTINGS_GROUP_ICONS } from '@/components/pipeline/pipelineUi';
+
+const s = strings.pipelineRunner;
+const groupLabels = s.settingsGroups;
+
+function settingsGroupLabel(labelKey: string): string {
+ return (groupLabels as Record)[labelKey] ?? labelKey;
+}
+
+export type PipelineNavId = 'run' | PipelineSettingsGroupId;
+
+export interface PipelineShellProps {
+ children: ReactNode;
+ activeNav: PipelineNavId;
+ onNavChange: (nav: PipelineNavId) => void;
+ headerExtra?: ReactNode;
+ footer?: ReactNode;
+}
+
+export default function PipelineShell({
+ children,
+ activeNav,
+ onNavChange,
+ headerExtra,
+ footer,
+}: PipelineShellProps) {
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const backHref = readPipelineReturnPath();
+
+ const closeSidebar = () => setSidebarOpen(false);
+
+ const navItemClass = (selected: boolean) =>
+ `nav-btn w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all ${
+ selected
+ ? 'tab-active bg-blue-500/10 border border-blue-500/25 text-link'
+ : 'text-muted-foreground hover:text-foreground hover:bg-brand-700/80'
+ }`;
+
+ const selectNav = (nav: PipelineNavId) => {
+ onNavChange(nav);
+ closeSidebar();
+ };
+
+ return (
+
+ {sidebarOpen ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ {activeNav === 'run'
+ ? s.runTitle
+ : settingsGroupLabel(
+ PIPELINE_SETTINGS_GROUPS.find((g) => g.id === activeNav)?.labelKey ?? '',
+ )}
+
+
+ {activeNav === 'run' ? s.runSubtitle : s.settingsSubtitle}
+
+
+
+
+ {headerExtra}
+
+
+
+
+
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+
+
+ );
+}
+
+export function pipelineNavFromSearchParams(
+ searchParams: URLSearchParams,
+): PipelineNavId {
+ const group = searchParams.get('group');
+ if (
+ group === 'crawl-report' ||
+ group === 'lighthouse' ||
+ group === 'keywords' ||
+ group === 'google' ||
+ group === 'content-ai' ||
+ group === 'advanced'
+ ) {
+ return group;
+ }
+ if (searchParams.get('tab') === 'settings') {
+ return 'crawl-report';
+ }
+ return 'run';
+}
+
+export function pipelineHrefForNav(
+ nav: PipelineNavId,
+ existingParams?: URLSearchParams,
+): string {
+ const params = new URLSearchParams(existingParams?.toString() ?? '');
+ params.delete('tab');
+ if (nav === 'run') {
+ params.delete('group');
+ } else {
+ params.set('group', nav);
+ }
+ const preset = params.get('preset');
+ if (preset) params.set('preset', preset);
+ const q = params.toString();
+ return q ? `/pipeline?${q}` : '/pipeline';
+}
diff --git a/web/src/components/pipeline/PipelineWizardProgress.tsx b/web/src/components/pipeline/PipelineWizardProgress.tsx
new file mode 100644
index 00000000..004317f9
--- /dev/null
+++ b/web/src/components/pipeline/PipelineWizardProgress.tsx
@@ -0,0 +1,82 @@
+'use client';
+
+import { Check } from 'lucide-react';
+import { strings } from '@/lib/strings';
+
+const s = strings.pipelineRunner;
+
+export type WizardStep = 1 | 2 | 3;
+
+const STEPS: { id: WizardStep; label: string }[] = [
+ { id: 1, label: s.wizardStepUrl },
+ { id: 2, label: s.wizardStepWorkflow },
+ { id: 3, label: s.wizardStepReview },
+];
+
+export interface PipelineWizardProgressProps {
+ currentStep: WizardStep;
+ maxReachableStep: WizardStep;
+ onStepClick?: (step: WizardStep) => void;
+}
+
+export default function PipelineWizardProgress({
+ currentStep,
+ maxReachableStep,
+ onStepClick,
+}: PipelineWizardProgressProps) {
+ return (
+
+ );
+}
diff --git a/web/src/components/pipeline/pipelinePresets.ts b/web/src/components/pipeline/pipelinePresets.ts
new file mode 100644
index 00000000..4717d2d5
--- /dev/null
+++ b/web/src/components/pipeline/pipelinePresets.ts
@@ -0,0 +1,60 @@
+import type { PipelineConfigState } from '@/types/api';
+
+export type PipelinePresetId =
+ | 'full-audit'
+ | 'crawl-only'
+ | 'report-only'
+ | 'lighthouse'
+ | 'google-sync'
+ | 'keywords-explorer';
+
+export interface PipelinePreset {
+ id: PipelinePresetId;
+ command: string;
+ configPatch?: Partial;
+}
+
+export const PIPELINE_PRESETS: PipelinePreset[] = [
+ {
+ id: 'full-audit',
+ command: '',
+ configPatch: {
+ run_crawl: true,
+ run_report: true,
+ run_plot: true,
+ },
+ },
+ { id: 'crawl-only', command: 'crawl' },
+ { id: 'report-only', command: 'report' },
+ { id: 'lighthouse', command: 'lighthouse' },
+ { id: 'google-sync', command: 'google' },
+ { id: 'keywords-explorer', command: 'keywords --enrich-google' },
+];
+
+export const DEFAULT_PRESET_ID: PipelinePresetId = 'full-audit';
+
+export function getPresetById(id: PipelinePresetId): PipelinePreset {
+ return PIPELINE_PRESETS.find((p) => p.id === id) ?? PIPELINE_PRESETS[0];
+}
+
+export function commandToPresetId(command: string): PipelinePresetId {
+ const match = PIPELINE_PRESETS.find((p) => p.command === command);
+ return match?.id ?? DEFAULT_PRESET_ID;
+}
+
+export function isPipelinePresetId(id: string): id is PipelinePresetId {
+ return PIPELINE_PRESETS.some((p) => p.id === id);
+}
+
+export function applyPreset(
+ presetId: PipelinePresetId,
+ configState: PipelineConfigState,
+): { command: string; configState: PipelineConfigState } {
+ const preset = getPresetById(presetId);
+ return {
+ command: preset.command,
+ configState: preset.configPatch
+ ? ({ ...configState, ...preset.configPatch } as PipelineConfigState)
+ : configState,
+ };
+}
diff --git a/web/src/components/pipeline/pipelineSettingsGroups.ts b/web/src/components/pipeline/pipelineSettingsGroups.ts
new file mode 100644
index 00000000..fac18dc8
--- /dev/null
+++ b/web/src/components/pipeline/pipelineSettingsGroups.ts
@@ -0,0 +1,51 @@
+export type PipelineSettingsGroupId =
+ | 'crawl-report'
+ | 'lighthouse'
+ | 'keywords'
+ | 'google'
+ | 'content-ai'
+ | 'advanced';
+
+export interface PipelineSettingsGroup {
+ id: PipelineSettingsGroupId;
+ /** Key under strings.pipelineRunner.settingsGroups */
+ labelKey: string;
+ sectionIds: string[];
+ includesLlm?: boolean;
+ defaultOpen?: boolean;
+}
+
+export const PIPELINE_SETTINGS_GROUPS: PipelineSettingsGroup[] = [
+ {
+ id: 'crawl-report',
+ labelKey: 'crawlReport',
+ sectionIds: ['crawl', 'report', 'pipeline'],
+ defaultOpen: true,
+ },
+ {
+ id: 'lighthouse',
+ labelKey: 'lighthouse',
+ sectionIds: ['lighthouse'],
+ },
+ {
+ id: 'keywords',
+ labelKey: 'keywords',
+ sectionIds: ['keywords_basics', 'keywords_expansion'],
+ },
+ {
+ id: 'google',
+ labelKey: 'google',
+ sectionIds: ['google'],
+ },
+ {
+ id: 'content-ai',
+ labelKey: 'contentAi',
+ sectionIds: ['analysis'],
+ includesLlm: true,
+ },
+ {
+ id: 'advanced',
+ labelKey: 'advanced',
+ sectionIds: ['advanced'],
+ },
+];
diff --git a/web/src/components/pipeline/pipelineUi.tsx b/web/src/components/pipeline/pipelineUi.tsx
new file mode 100644
index 00000000..25153298
--- /dev/null
+++ b/web/src/components/pipeline/pipelineUi.tsx
@@ -0,0 +1,132 @@
+import type { LucideIcon } from 'lucide-react';
+import {
+ BarChart3,
+ Check,
+ FileText,
+ Gauge,
+ Globe,
+ KeyRound,
+ Loader2,
+ ScanSearch,
+ Sparkles,
+ Wrench,
+} from 'lucide-react';
+import type { PipelineJobStatus } from '@/types/api';
+import type { PipelinePresetId } from './pipelinePresets';
+import type { PipelineSettingsGroupId } from './pipelineSettingsGroups';
+import { strings } from '@/lib/strings';
+
+const presetStrings = strings.pipelineRunner.presets;
+
+export const PRESET_COPY: Record = {
+ 'full-audit': {
+ label: presetStrings.fullAudit.label,
+ description: presetStrings.fullAudit.description,
+ },
+ 'crawl-only': {
+ label: presetStrings.crawlOnly.label,
+ description: presetStrings.crawlOnly.description,
+ },
+ 'report-only': {
+ label: presetStrings.reportOnly.label,
+ description: presetStrings.reportOnly.description,
+ },
+ lighthouse: {
+ label: presetStrings.lighthouse.label,
+ description: presetStrings.lighthouse.description,
+ },
+ 'google-sync': {
+ label: presetStrings.googleSync.label,
+ description: presetStrings.googleSync.description,
+ },
+ 'keywords-explorer': {
+ label: presetStrings.keywordsExplorer.label,
+ description: presetStrings.keywordsExplorer.description,
+ },
+};
+
+export function getPresetLabel(id: PipelinePresetId): string {
+ return PRESET_COPY[id]?.label ?? id;
+}
+
+export const PRESET_ICONS: Record = {
+ 'full-audit': ScanSearch,
+ 'crawl-only': Globe,
+ 'report-only': FileText,
+ lighthouse: Gauge,
+ 'google-sync': BarChart3,
+ 'keywords-explorer': KeyRound,
+};
+
+export const SETTINGS_GROUP_ICONS: Record = {
+ 'crawl-report': FileText,
+ lighthouse: Gauge,
+ keywords: KeyRound,
+ google: BarChart3,
+ 'content-ai': Sparkles,
+ advanced: Wrench,
+};
+
+const STATUS_STYLES: Record = {
+ starting: 'bg-amber-500/15 text-amber-800 dark:text-amber-300 border-amber-500/30',
+ running: 'bg-blue-500/15 text-blue-800 dark:text-blue-300 border-blue-500/30',
+ success: 'bg-green-500/15 text-green-800 dark:text-green-300 border-green-500/30',
+ error: 'bg-red-500/15 text-red-800 dark:text-red-300 border-red-500/30',
+};
+
+export function PipelineStatusBadge({
+ status,
+ busy,
+ label,
+}: {
+ status: PipelineJobStatus | '';
+ busy?: boolean;
+ label?: string;
+}) {
+ if (!status && !busy) return null;
+
+ const key = busy && status !== 'error' ? 'running' : status || 'running';
+ const classes = STATUS_STYLES[key] ?? STATUS_STYLES.running;
+ const display = label ?? (busy && !status ? 'running' : status);
+
+ return (
+
+ {busy && status !== 'success' && status !== 'error' ? (
+
+ ) : status === 'success' ? (
+
+ ) : (
+
+ )}
+ {display}
+
+ );
+}
+
+export function PresetIcon({
+ presetId,
+ selected,
+ className = '',
+}: {
+ presetId: PipelinePresetId;
+ selected?: boolean;
+ className?: string;
+}) {
+ const Icon = PRESET_ICONS[presetId];
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/searchPerformance/GscCharts.jsx b/web/src/components/searchPerformance/GscCharts.tsx
similarity index 80%
rename from web/src/components/searchPerformance/GscCharts.jsx
rename to web/src/components/searchPerformance/GscCharts.tsx
index eae98c02..a0990fce 100644
--- a/web/src/components/searchPerformance/GscCharts.jsx
+++ b/web/src/components/searchPerformance/GscCharts.tsx
@@ -1,6 +1,7 @@
'use client';
-import { useMemo } from 'react';
+import { useMemo, type ReactNode } from 'react';
+import type { ChartOptions, TooltipItem } from 'chart.js';
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement, Title, Tooltip, Legend } from 'chart.js';
import { Bar, Scatter } from 'react-chartjs-2';
import { palette, sortByValue } from '../../utils/chartPalette';
@@ -15,6 +16,7 @@ import { buildPositionBuckets } from './gscTableUtils';
import { truncateLabel } from '../google/tableUtils';
import GoogleChartCard from '../google/GoogleChartCard';
import GoogleTimeSeriesChart from '../google/GoogleTimeSeriesChart';
+import type { GscDailyRow, GscPageRow, GscQueryRow, ScatterPoint } from '@/types/components';
registerChartJsBase();
ChartJS.register(PointElement);
@@ -22,7 +24,15 @@ ChartJS.register(PointElement);
const TOP_N = 10;
const SCATTER_MAX = 50;
-function ChartCard({ title, hint, ariaLabel, heightClass = 'h-56', children }) {
+interface ChartCardProps {
+ title: string;
+ hint?: string;
+ ariaLabel: string;
+ heightClass?: string;
+ children?: ReactNode;
+}
+
+function ChartCard({ title, hint, ariaLabel, heightClass = 'h-56', children }: ChartCardProps) {
return (
{title}
@@ -34,12 +44,19 @@ function ChartCard({ title, hint, ariaLabel, heightClass = 'h-56', children }) {
);
}
-function useTopBarChart(rows, labelKey, valueKey, sp) {
+function useTopBarChart(
+ rows: Array > | null | undefined,
+ labelKey: string,
+ valueKey: string,
+ sp: typeof strings.views.searchPerformance,
+) {
return useMemo(() => {
if (!rows?.length) return null;
- const sorted = [...rows].sort((a, b) => (b[valueKey] || 0) - (a[valueKey] || 0)).slice(0, TOP_N);
+ const sorted = [...rows]
+ .sort((a, b) => Number(b[valueKey] || 0) - Number(a[valueKey] || 0))
+ .slice(0, TOP_N);
const labels = sorted.map((r) => truncateLabel(r[labelKey]));
- const values = sorted.map((r) => r[valueKey] || 0);
+ const values = sorted.map((r) => Number(r[valueKey] || 0));
const { labels: sortedLabels, values: sortedValues } = sortByValue(labels, values, 'asc');
return {
data: {
@@ -57,7 +74,11 @@ function useTopBarChart(rows, labelKey, valueKey, sp) {
}, [rows, labelKey, valueKey, sp]);
}
-export function TopQueriesBarChart({ queries }) {
+interface TopQueriesBarChartProps {
+ queries: GscQueryRow[];
+}
+
+export function TopQueriesBarChart({ queries }: TopQueriesBarChartProps) {
const sp = strings.views.searchPerformance;
const chart = useTopBarChart(queries, 'query', 'clicks', sp);
const opts = useMemo(() => barOptionsHorizontal(sp.charts.axisClicks), [sp]);
@@ -79,7 +100,11 @@ export function TopQueriesBarChart({ queries }) {
);
}
-export function TopPagesBarChart({ pages }) {
+interface TopPagesBarChartProps {
+ pages: GscPageRow[];
+}
+
+export function TopPagesBarChart({ pages }: TopPagesBarChartProps) {
const sp = strings.views.searchPerformance;
const chart = useTopBarChart(pages, 'page', 'clicks', sp);
const opts = useMemo(() => barOptionsHorizontal(sp.charts.axisClicks), [sp]);
@@ -101,7 +126,11 @@ export function TopPagesBarChart({ pages }) {
);
}
-export function PositionDistributionChart({ queries }) {
+interface PositionDistributionChartProps {
+ queries: GscQueryRow[];
+}
+
+export function PositionDistributionChart({ queries }: PositionDistributionChartProps) {
const sp = strings.views.searchPerformance;
const chart = useMemo(() => {
const buckets = buildPositionBuckets(queries);
@@ -121,7 +150,7 @@ export function PositionDistributionChart({ queries }) {
};
}, [queries, sp]);
- const opts = useMemo(() => {
+ const opts = useMemo((): ChartOptions<'bar'> => {
const grid = getGridColor();
const titleColor = getChartTitleColor();
return {
@@ -158,7 +187,11 @@ export function PositionDistributionChart({ queries }) {
export { default as UrlCoverageDoughnut } from '../google/UrlCoverageDoughnut';
-export function GscDailyTrendChart({ daily }) {
+interface GscDailyTrendChartProps {
+ daily: GscDailyRow[];
+}
+
+export function GscDailyTrendChart({ daily }: GscDailyTrendChartProps) {
const sp = strings.views.searchPerformance;
const series = [
{ key: 'clicks', label: sp.charts.axisClicks },
@@ -177,15 +210,19 @@ export function GscDailyTrendChart({ daily }) {
);
}
-export function CtrOpportunityScatter({ rows }) {
+interface CtrOpportunityScatterProps {
+ rows: GscQueryRow[];
+}
+
+export function CtrOpportunityScatter({ rows }: CtrOpportunityScatterProps) {
const sp = strings.views.searchPerformance;
const chart = useMemo(() => {
const source = (rows || []).slice(0, SCATTER_MAX);
- const points = source
- .filter((r) => r.impressions > 0)
+ const points: ScatterPoint[] = source
+ .filter((r) => (r.impressions ?? 0) > 0)
.map((r) => ({
- x: r.impressions,
- y: parseFloat(r.ctr) || 0,
+ x: r.impressions ?? 0,
+ y: parseFloat(String(r.ctr)) || 0,
query: r.query,
clicks: r.clicks,
}));
@@ -203,7 +240,7 @@ export function CtrOpportunityScatter({ rows }) {
};
}, [rows]);
- const opts = useMemo(() => {
+ const opts = useMemo((): ChartOptions<'scatter'> => {
const grid = getGridColor();
const titleColor = getChartTitleColor();
return {
@@ -213,8 +250,8 @@ export function CtrOpportunityScatter({ rows }) {
legend: { display: false },
tooltip: {
callbacks: {
- label: (ctx) => {
- const r = ctx.raw;
+ label: (ctx: TooltipItem<'scatter'>) => {
+ const r = ctx.raw as ScatterPoint;
const q = r.query ? truncateLabel(r.query, 36) : '';
const lines = [
`${sp.charts.axisImpressions}: ${r.x?.toLocaleString()}`,
diff --git a/web/src/components/searchPerformance/gscTableUtils.js b/web/src/components/searchPerformance/gscTableUtils.ts
similarity index 66%
rename from web/src/components/searchPerformance/gscTableUtils.js
rename to web/src/components/searchPerformance/gscTableUtils.ts
index 8decd2e6..bc7e89b7 100644
--- a/web/src/components/searchPerformance/gscTableUtils.js
+++ b/web/src/components/searchPerformance/gscTableUtils.ts
@@ -11,20 +11,21 @@ export {
} from '../google/tableUtils';
import { PAGE_SIZE } from '../google/tableUtils';
+import type { ExportColumn, GscPageRow, GscQueryRow } from '@/types/components';
/** @deprecated Use PAGE_SIZE */
export const DEFAULT_TABLE_ROWS = PAGE_SIZE;
-export function filterOpportunities(queries) {
+export function filterOpportunities(queries: GscQueryRow[]): GscQueryRow[] {
if (!queries?.length) return [];
- return queries.filter((q) => q.impressions >= 50 && q.ctr < 3);
+ return queries.filter((q) => (q.impressions ?? 0) >= 50 && (q.ctr ?? 0) < 3);
}
/** Bucket queries/pages by average position for distribution chart. */
-export function buildPositionBuckets(rows) {
+export function buildPositionBuckets(rows: Array<{ position?: number | string }>): number[] {
const buckets = [0, 0, 0, 0];
for (const r of rows || []) {
- const pos = parseFloat(r.position);
+ const pos = parseFloat(String(r.position));
if (!pos || pos <= 0) continue;
if (pos <= 3) buckets[0] += 1;
else if (pos <= 10) buckets[1] += 1;
@@ -34,7 +35,7 @@ export function buildPositionBuckets(rows) {
return buckets;
}
-export function buildQueryExportColumns(sp) {
+export function buildQueryExportColumns(sp: { table: Record }): ExportColumn[] {
return [
{ key: 'query', label: sp.table.query },
{ key: 'clicks', label: sp.table.clicks },
@@ -44,7 +45,7 @@ export function buildQueryExportColumns(sp) {
];
}
-export function buildPageExportColumns(sp) {
+export function buildPageExportColumns(sp: { table: Record }): ExportColumn[] {
return [
{ key: 'page', label: sp.table.page },
{ key: 'clicks', label: sp.table.clicks },
@@ -53,3 +54,5 @@ export function buildPageExportColumns(sp) {
{ key: 'position', label: sp.table.position },
];
}
+
+export type { GscPageRow, GscQueryRow };
diff --git a/web/src/components/siteStructure/PathTreeTable.jsx b/web/src/components/siteStructure/PathTreeTable.tsx
similarity index 86%
rename from web/src/components/siteStructure/PathTreeTable.jsx
rename to web/src/components/siteStructure/PathTreeTable.tsx
index acb05939..86ff3857 100644
--- a/web/src/components/siteStructure/PathTreeTable.jsx
+++ b/web/src/components/siteStructure/PathTreeTable.tsx
@@ -1,10 +1,17 @@
import { ChevronRight, ChevronDown, Folder, Home } from 'lucide-react';
-import Table, { TableHead, TableHeadCell, TableBody, TableRow, TableCell } from '../Table.jsx';
+import Table, { TableHead, TableHeadCell, TableBody, TableRow, TableCell } from '../Table';
+import type { PathTreeTableRow } from '@/types/report';
+
+interface ComparePairBarProps {
+ current: number;
+ baseline: number;
+ title?: string;
+}
/**
* Two-segment bar: baseline (muted) vs current (blue) proportional split.
*/
-function ComparePairBar({ current, baseline, title }) {
+function ComparePairBar({ current, baseline, title }: ComparePairBarProps) {
const c = Math.max(0, Number(current) || 0);
const b = Math.max(0, Number(baseline) || 0);
const t = c + b;
@@ -24,32 +31,39 @@ function ComparePairBar({ current, baseline, title }) {
);
}
-function fmtInt(n) {
+function fmtInt(n: unknown): string {
if (n == null || !Number.isFinite(Number(n))) return '—';
return Math.round(Number(n)).toLocaleString();
}
-function fmtAvg(n) {
+function fmtAvg(n: unknown): string {
if (n == null || !Number.isFinite(Number(n))) return '—';
return Math.round(Number(n)).toLocaleString();
}
-function fmtScore(n) {
+function fmtScore(n: unknown): string {
if (n == null || !Number.isFinite(Number(n))) return '—';
return String(Math.round(Number(n)));
}
-/**
- * @param {object} props
- * @param {Array | |