Make domain overview URL paths clickable in tables (#120)
This commit is contained in:
parent
e78ef5e3dd
commit
2b7a3eae05
34
src/client/components/SafeExternalLink.tsx
Normal file
34
src/client/components/SafeExternalLink.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}`}`;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user