Make domain overview URL paths clickable in tables (#120)

This commit is contained in:
Ben Senescu 2026-04-16 10:30:49 -04:00 committed by GitHub
parent e78ef5e3dd
commit 2b7a3eae05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 127 additions and 60 deletions

View File

@ -0,0 +1,34 @@
import { ExternalLink } from "lucide-react";
export function SafeExternalLink({
url,
label,
className,
}: {
url: string;
label: string;
className: string;
}) {
const safeUrl = getSafeExternalUrl(url);
if (!safeUrl) {
return <span className={className}>{label}</span>;
}
return (
<a className={className} href={safeUrl} target="_blank" rel="noreferrer">
{label}
<ExternalLink className="size-3 shrink-0" />
</a>
);
}
function getSafeExternalUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: null;
} catch {
return null;
}
}

View File

@ -1,4 +1,4 @@
import { ExternalLink } from "lucide-react"; import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { extractUrlPath, truncateMiddle } from "./backlinksPageUtils"; import { extractUrlPath, truncateMiddle } from "./backlinksPageUtils";
export function BacklinksExternalLink({ export function BacklinksExternalLink({
@ -10,17 +10,7 @@ export function BacklinksExternalLink({
label: string; label: string;
className: string; className: string;
}) { }) {
const safeUrl = getSafeExternalUrl(url); return <SafeExternalLink url={url} label={label} className={className} />;
if (!safeUrl) {
return <span className={className}>{label}</span>;
}
return (
<a className={className} href={safeUrl} target="_blank" rel="noreferrer">
{label}
<ExternalLink className="size-3 shrink-0" />
</a>
);
} }
export function BacklinksSourceLink({ export function BacklinksSourceLink({
@ -40,14 +30,3 @@ export function BacklinksSourceLink({
/> />
); );
} }
function getSafeExternalUrl(value: string) {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: null;
} catch {
return null;
}
}

View File

@ -1,6 +1,11 @@
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge"; import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
import { SortableHeader } from "@/client/features/domain/components/SortableHeader"; import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import { formatFloat, formatNumber } from "@/client/features/domain/utils"; import {
formatFloat,
formatNumber,
resolveDomainPageHref,
} from "@/client/features/domain/utils";
import type { import type {
DomainSortMode, DomainSortMode,
KeywordRow, KeywordRow,
@ -8,6 +13,7 @@ import type {
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
type Props = { type Props = {
domain: string;
rows: KeywordRow[]; rows: KeywordRow[];
selectedKeywords: Set<string>; selectedKeywords: Set<string>;
visibleKeywords: string[]; visibleKeywords: string[];
@ -19,6 +25,7 @@ type Props = {
}; };
export function DomainKeywordsTable({ export function DomainKeywordsTable({
domain,
rows, rows,
selectedKeywords, selectedKeywords,
visibleKeywords, visibleKeywords,
@ -105,33 +112,48 @@ export function DomainKeywordsTable({
</td> </td>
</tr> </tr>
) : ( ) : (
rows.slice(0, 100).map((row) => ( rows.slice(0, 100).map((row) => {
<tr key={`${row.keyword}-${row.url ?? ""}`}> const href = resolveDomainPageHref(
<td> row.relativeUrl ?? row.url,
<input domain,
type="checkbox" );
className="checkbox checkbox-xs"
checked={selectedKeywords.has(row.keyword)} return (
onChange={() => onToggleKeyword(row.keyword)} <tr key={`${row.keyword}-${row.url ?? ""}`}>
aria-label={`Select ${row.keyword}`} <td>
/> <input
</td> type="checkbox"
<td className="font-medium">{row.keyword}</td> className="checkbox checkbox-xs"
<td>{row.position ?? "-"}</td> checked={selectedKeywords.has(row.keyword)}
<td>{formatNumber(row.searchVolume)}</td> onChange={() => onToggleKeyword(row.keyword)}
<td>{formatFloat(row.traffic)}</td> aria-label={`Select ${row.keyword}`}
<td>{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}</td> />
<td </td>
className="max-w-[260px] truncate" <td className="font-medium">{row.keyword}</td>
title={row.url ?? undefined} <td>{row.position ?? "-"}</td>
> <td>{formatNumber(row.searchVolume)}</td>
{row.relativeUrl ?? row.url ?? "-"} <td>{formatFloat(row.traffic)}</td>
</td> <td>{row.cpc == null ? "-" : `$${row.cpc.toFixed(2)}`}</td>
<td> <td
<DifficultyBadge value={row.keywordDifficulty} /> className="max-w-[260px] truncate"
</td> title={row.url ?? undefined}
</tr> >
)) {href ? (
<SafeExternalLink
url={href}
label={row.relativeUrl ?? row.url ?? ""}
className="link link-primary inline-flex items-center gap-1"
/>
) : (
"-"
)}
</td>
<td>
<DifficultyBadge value={row.keywordDifficulty} />
</td>
</tr>
);
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@ -1,7 +1,9 @@
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
import { SortableHeader } from "@/client/features/domain/components/SortableHeader"; import { SortableHeader } from "@/client/features/domain/components/SortableHeader";
import { import {
formatFloat, formatFloat,
formatNumber, formatNumber,
resolveDomainPageHref,
toPageSortMode, toPageSortMode,
} from "@/client/features/domain/utils"; } from "@/client/features/domain/utils";
import type { import type {
@ -11,6 +13,7 @@ import type {
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
type Props = { type Props = {
domain: string;
rows: PageRow[]; rows: PageRow[];
sortMode: DomainSortMode; sortMode: DomainSortMode;
currentSortOrder: SortOrder; currentSortOrder: SortOrder;
@ -18,6 +21,7 @@ type Props = {
}; };
export function DomainPagesTable({ export function DomainPagesTable({
domain,
rows, rows,
sortMode, sortMode,
currentSortOrder, currentSortOrder,
@ -55,15 +59,30 @@ export function DomainPagesTable({
</td> </td>
</tr> </tr>
) : ( ) : (
rows.slice(0, 100).map((row) => ( rows.slice(0, 100).map((row) => {
<tr key={row.page}> const href = resolveDomainPageHref(
<td className="max-w-[420px] truncate" title={row.page}> row.relativePath ?? row.page,
{row.relativePath ?? row.page} domain,
</td> );
<td>{formatFloat(row.organicTraffic)}</td>
<td>{formatNumber(row.keywords)}</td> return (
</tr> <tr key={row.page}>
)) <td className="max-w-[420px] truncate" title={row.page}>
{href ? (
<SafeExternalLink
url={href}
label={row.relativePath ?? row.page}
className="link link-primary inline-flex items-center gap-1"
/>
) : (
(row.relativePath ?? row.page)
)}
</td>
<td>{formatFloat(row.organicTraffic)}</td>
<td>{formatNumber(row.keywords)}</td>
</tr>
);
})
)} )}
</tbody> </tbody>
</table> </table>

View File

@ -205,6 +205,7 @@ export function DomainResultsCard({
<div className="p-4"> <div className="p-4">
{isKeywordsTab ? ( {isKeywordsTab ? (
<DomainKeywordsTable <DomainKeywordsTable
domain={overview.domain}
rows={filteredKeywords} rows={filteredKeywords}
selectedKeywords={selectedKeywords} selectedKeywords={selectedKeywords}
visibleKeywords={visibleKeywords} visibleKeywords={visibleKeywords}
@ -216,6 +217,7 @@ export function DomainResultsCard({
/> />
) : ( ) : (
<DomainPagesTable <DomainPagesTable
domain={overview.domain}
rows={filteredPages} rows={filteredPages}
sortMode={sortMode} sortMode={sortMode}
currentSortOrder={currentSortOrder} currentSortOrder={currentSortOrder}

View File

@ -136,3 +136,14 @@ export function pagesToCsv(rows: PageRow[]): string {
export function downloadCsv(content: string, filename: string) { export function downloadCsv(content: string, filename: string) {
downloadCsvFile(filename, content); downloadCsvFile(filename, content);
} }
export function resolveDomainPageHref(
value: string | null | undefined,
domain: string,
): string | null {
if (!value) return null;
return value.includes("://")
? value
: `https://${domain}${value.startsWith("/") ? value : `/${value}`}`;
}