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

View File

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

View File

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

View File

@ -136,3 +136,14 @@ export function pagesToCsv(rows: PageRow[]): string {
export function downloadCsv(content: string, filename: string) {
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}`}`;
}