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";
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}`}`;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user