← back to Open Seo
src/client/features/lighthouse/issues/LighthouseIssuesParts.tsx
364 lines
import {
ChevronDown,
Copy,
Download,
FileWarning,
Info,
Sheet,
TriangleAlert,
} from "lucide-react";
import type {
CategoryTab,
ExportPayload,
LighthouseIssue,
LighthouseMetrics,
LighthouseScores,
} from "./types";
import { LighthouseIssueRow } from "./LighthouseIssueRow";
import { LighthouseIssuesSummary } from "./LighthouseIssuesSummary";
import { categoryLabel } from "./utils";
import { categoryTabs } from "./types";
export function LighthouseIssuesHeader({
backLabel,
onBack,
scannedAt,
finalUrl,
scores,
metrics,
severityCounts,
}: {
backLabel: string;
onBack: () => void;
scannedAt?: string;
finalUrl?: string;
scores?: LighthouseScores | null;
metrics?: LighthouseMetrics | null;
severityCounts: { critical: number; warning: number; info: number };
}) {
return (
<>
<div className="flex items-center justify-between gap-3">
<button className="btn btn-ghost btn-sm px-2" onClick={onBack}>
← Back to {backLabel}
</button>
<span className="text-xs text-base-content/60">
{scannedAt
? `Scanned ${new Date(scannedAt).toLocaleString()}`
: "Reading latest issues..."}
</span>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body py-5 gap-4">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Lighthouse Issues</h1>
<p className="text-sm text-base-content/70 break-all">
{finalUrl ?? "Loading URL..."}
</p>
</div>
<LighthouseIssuesSummary scores={scores} metrics={metrics} />
<div className="flex flex-wrap gap-2 text-xs">
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
<FileWarning className="size-3" />
Critical {severityCounts.critical}
</span>
<span className="badge border border-warning/30 bg-warning/10 text-warning/80 gap-1">
<TriangleAlert className="size-3" />
Warning {severityCounts.warning}
</span>
<span className="badge border border-info/30 bg-info/10 text-info/80 gap-1">
<Info className="size-3" />
Info {severityCounts.info}
</span>
</div>
</div>
</div>
</>
);
}
export function LighthouseIssuesToolbar({
category,
categoryCounts,
selectedCategoryLabel,
isBusy,
visibleIssues,
allIssues,
onCategoryChange,
onCopy,
onExport,
onExportCsv,
onExportSheets,
}: {
category: CategoryTab;
categoryCounts: Record<CategoryTab, number>;
selectedCategoryLabel: string;
isBusy: boolean;
visibleIssues: LighthouseIssue[];
allIssues: LighthouseIssue[];
onCategoryChange: (next: CategoryTab) => void;
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
onExportSheets: (
issues: LighthouseIssue[],
variant: "all" | "current",
) => void;
}) {
const exportCurrentCategory: ExportPayload =
category === "all" ? { mode: "issues" } : { mode: "category", category };
const categoryLabelLower = selectedCategoryLabel.toLowerCase();
return (
<div className="sticky top-0 z-[2] -mx-2 px-2 py-2 bg-base-100/95 backdrop-blur-sm border-b border-base-300/60">
<div className="flex flex-wrap items-center justify-between gap-3">
<CategoryTabs
category={category}
categoryCounts={categoryCounts}
onCategoryChange={onCategoryChange}
/>
<ExportMenu
allIssues={allIssues}
categoryLabelLower={categoryLabelLower}
exportCurrentCategory={exportCurrentCategory}
isBusy={isBusy}
onCopy={onCopy}
onExport={onExport}
onExportCsv={onExportCsv}
onExportSheets={onExportSheets}
visibleIssues={visibleIssues}
/>
</div>
</div>
);
}
function CategoryTabs({
category,
categoryCounts,
onCategoryChange,
}: {
category: CategoryTab;
categoryCounts: Record<CategoryTab, number>;
onCategoryChange: (next: CategoryTab) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-4">
{categoryTabs.map((tab) => (
<button
key={tab}
className={`pb-2 border-b-2 text-sm font-medium transition-colors ${
category === tab
? "border-primary text-base-content"
: "border-transparent text-base-content/60 hover:text-base-content"
}`}
onClick={() => onCategoryChange(tab)}
>
<span>{categoryLabel(tab)}</span>
<span className="ml-1 text-xs opacity-70">
({categoryCounts[tab]})
</span>
</button>
))}
</div>
);
}
function ExportMenu({
allIssues,
categoryLabelLower,
exportCurrentCategory,
isBusy,
onCopy,
onExport,
onExportCsv,
onExportSheets,
visibleIssues,
}: {
allIssues: LighthouseIssue[];
categoryLabelLower: string;
exportCurrentCategory: ExportPayload;
isBusy: boolean;
onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void;
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
onExportSheets: (
issues: LighthouseIssue[],
variant: "all" | "current",
) => void;
visibleIssues: LighthouseIssue[];
}) {
return (
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-72"
>
<li className="menu-title">
<span>Export to Sheets</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => onExportSheets(visibleIssues, "current")}
>
<Sheet className="size-4" />
Open in Sheets — {categoryLabelLower}
</button>
</li>
<li>
<button
disabled={!allIssues.length}
onClick={() => onExportSheets(allIssues, "all")}
>
<Sheet className="size-4" />
Open in Sheets — all actionable
</button>
</li>
<li className="menu-title">
<span>Copy</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
onCopy(
exportCurrentCategory,
`Copied ${categoryLabelLower} issues`,
)
}
>
<Copy className="size-4" />
Copy {categoryLabelLower} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
onCopy({ mode: "issues" }, "Copied all actionable issues")
}
>
<Copy className="size-4" />
Copy all actionable issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() =>
onCopy({ mode: "full" }, "Copied saved Lighthouse payload")
}
>
<Copy className="size-4" />
Copy saved Lighthouse payload
</button>
</li>
<li className="menu-title">
<span>Download JSON</span>
</li>
<li>
<button
disabled={isBusy}
onClick={() => onExport(exportCurrentCategory)}
>
Download {categoryLabelLower} issues
</button>
</li>
<li>
<button
disabled={isBusy}
onClick={() => onExport({ mode: "issues" })}
>
Download all actionable issues
</button>
</li>
<li>
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
Download saved Lighthouse payload
</button>
</li>
<li className="menu-title">
<span>Download CSV</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => onExportCsv(visibleIssues, "current")}
>
Download {categoryLabelLower} issues
</button>
</li>
<li>
<button
disabled={!allIssues.length}
onClick={() => onExportCsv(allIssues, "all")}
>
Download all actionable issues
</button>
</li>
</ul>
</div>
);
}
export function LighthouseIssueList({
issues,
isLoading,
emptyMessage,
}: {
issues: LighthouseIssue[];
isLoading: boolean;
emptyMessage?: string;
}) {
if (isLoading) {
return <p className="text-sm text-base-content/60">Loading issues...</p>;
}
if (!issues.length) {
return (
<p className="text-sm text-base-content/60">
{emptyMessage ?? "No actionable issues for this category."}
</p>
);
}
return (
<table className="table table-sm w-full table-fixed">
<colgroup>
<col className="w-8" />
<col className="w-24" />
<col />
<col className="w-28 hidden sm:table-column" />
<col className="w-28 hidden md:table-column" />
<col className="w-14" />
</colgroup>
<thead>
<tr className="text-xs text-base-content/50 uppercase tracking-wide border-b border-base-300">
<th />
<th className="font-medium">Severity</th>
<th className="font-medium">Issue</th>
<th className="font-medium hidden sm:table-cell">Category</th>
<th className="font-medium hidden md:table-cell text-right">
Impact
</th>
<th className="font-medium text-right">Score</th>
</tr>
</thead>
<tbody className="divide-y divide-base-300/60">
{issues.map((issue, issueIndex) => (
<LighthouseIssueRow
key={`${issue.category}-${issue.auditKey}-${issueIndex}`}
issue={issue}
/>
))}
</tbody>
</table>
);
}