feat: Add shared table component (#154)
* fix rank tracking unranked filters * improve modal dismissal and range selection * refactor: create shared table component
This commit is contained in:
parent
7f892ca433
commit
9e5afc9d48
246
src/client/components/table/AppDataTable.tsx
Normal file
246
src/client/components/table/AppDataTable.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getExpandedRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type Header,
|
||||||
|
type Row,
|
||||||
|
type Table,
|
||||||
|
type TableOptions,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
useRef,
|
||||||
|
type MouseEvent,
|
||||||
|
type MutableRefObject,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
applyShiftRangeSelection,
|
||||||
|
type SelectionAnchor,
|
||||||
|
} from "./tableSelection";
|
||||||
|
|
||||||
|
type AppColumnMeta<TData> = {
|
||||||
|
headerClassName?: string;
|
||||||
|
cellClassName?: string | ((row: Row<TData>) => string | undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
declare module "@tanstack/react-table" {
|
||||||
|
interface ColumnMeta<TData, TValue> extends AppColumnMeta<TData> {
|
||||||
|
readonly __valueType?: TValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseAppTableOptions<TData> = Omit<
|
||||||
|
TableOptions<TData>,
|
||||||
|
"getCoreRowModel"
|
||||||
|
> & {
|
||||||
|
withSorting?: boolean;
|
||||||
|
withExpanded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAppTable<TData>(options: UseAppTableOptions<TData>) {
|
||||||
|
const { withSorting, withExpanded, ...tableOptions } = options;
|
||||||
|
return useReactTable({
|
||||||
|
...tableOptions,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
...(withSorting ? { getSortedRowModel: getSortedRowModel() } : {}),
|
||||||
|
...(withExpanded ? { getExpandedRowModel: getExpandedRowModel() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectionAnchor(): MutableRefObject<SelectionAnchor | null> {
|
||||||
|
return useRef<SelectionAnchor | null>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSelectionColumn<TData>(
|
||||||
|
anchorRef: MutableRefObject<SelectionAnchor | null>,
|
||||||
|
): ColumnDef<TData> {
|
||||||
|
return {
|
||||||
|
id: "select",
|
||||||
|
size: 32,
|
||||||
|
enableSorting: false,
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row, table }) => (
|
||||||
|
<SelectionCheckbox row={row} table={table} anchorRef={anchorRef} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectionCheckbox<TData>({
|
||||||
|
row,
|
||||||
|
table,
|
||||||
|
anchorRef,
|
||||||
|
}: {
|
||||||
|
row: Row<TData>;
|
||||||
|
table: Table<TData>;
|
||||||
|
anchorRef: MutableRefObject<SelectionAnchor | null>;
|
||||||
|
}) {
|
||||||
|
const rangeHandledRef = useRef(false);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs [--radius-selector:0.25rem]"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
rangeHandledRef.current = applyShiftRangeSelection(
|
||||||
|
event,
|
||||||
|
row,
|
||||||
|
table,
|
||||||
|
anchorRef,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (rangeHandledRef.current) {
|
||||||
|
rangeHandledRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.getToggleSelectedHandler()(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppDataTable<TData>({
|
||||||
|
table,
|
||||||
|
className = "table table-sm",
|
||||||
|
wrapperClassName = "overflow-x-auto",
|
||||||
|
empty,
|
||||||
|
isLoading,
|
||||||
|
loading,
|
||||||
|
getRowClassName,
|
||||||
|
getRowProps,
|
||||||
|
getCellClassName,
|
||||||
|
fixedLayout,
|
||||||
|
stickyHeader,
|
||||||
|
}: {
|
||||||
|
table: Table<TData>;
|
||||||
|
className?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
empty?: ReactNode;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loading?: ReactNode;
|
||||||
|
getRowClassName?: (row: Row<TData>) => string | undefined;
|
||||||
|
getRowProps?: (row: Row<TData>) => {
|
||||||
|
onClick?: (event: MouseEvent<HTMLTableRowElement>) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
getCellClassName?: (row: Row<TData>, columnId: string) => string | undefined;
|
||||||
|
fixedLayout?: boolean;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
}) {
|
||||||
|
if (isLoading && loading) return <>{loading}</>;
|
||||||
|
if (table.getRowModel().rows.length === 0 && empty) return <>{empty}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapperClassName}>
|
||||||
|
<table
|
||||||
|
className={className}
|
||||||
|
style={fixedLayout ? { tableLayout: "fixed" } : undefined}
|
||||||
|
>
|
||||||
|
{fixedLayout ? (
|
||||||
|
<colgroup>
|
||||||
|
{table.getVisibleLeafColumns().map((column) => (
|
||||||
|
<col key={column.id} style={{ width: column.getSize() }} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
) : null}
|
||||||
|
<thead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<HeaderCell
|
||||||
|
key={header.id}
|
||||||
|
header={header}
|
||||||
|
fixedLayout={fixedLayout}
|
||||||
|
stickyHeader={stickyHeader}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{table.getRowModel().rows.map((row) => {
|
||||||
|
const rowProps = getRowProps?.(row);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
onClick={rowProps?.onClick}
|
||||||
|
className={[getRowClassName?.(row), rowProps?.className]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
const rawMeta: unknown = cell.column.columnDef.meta;
|
||||||
|
const meta = isAppColumnMeta<TData>(rawMeta)
|
||||||
|
? rawMeta
|
||||||
|
: undefined;
|
||||||
|
const metaClass = meta?.cellClassName;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
className={[
|
||||||
|
typeof metaClass === "function"
|
||||||
|
? metaClass(row)
|
||||||
|
: metaClass,
|
||||||
|
getCellClassName?.(row, cell.column.id),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderCell<TData>({
|
||||||
|
header,
|
||||||
|
fixedLayout,
|
||||||
|
stickyHeader,
|
||||||
|
}: {
|
||||||
|
header: Header<TData, unknown>;
|
||||||
|
fixedLayout?: boolean;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
}) {
|
||||||
|
const rawMeta: unknown = header.column.columnDef.meta;
|
||||||
|
const meta = isAppColumnMeta<TData>(rawMeta) ? rawMeta : undefined;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={[
|
||||||
|
stickyHeader ? "bg-base-200" : undefined,
|
||||||
|
meta?.headerClassName,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
style={fixedLayout ? { width: header.getSize() } : undefined}
|
||||||
|
>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAppColumnMeta<TData>(value: unknown): value is AppColumnMeta<TData> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { MouseEvent, MutableRefObject } from "react";
|
import type { MouseEvent, MutableRefObject } from "react";
|
||||||
import type { Row, Table } from "@tanstack/react-table";
|
import type { Row, RowSelectionState, Table } from "@tanstack/react-table";
|
||||||
|
|
||||||
export type SelectionAnchor = {
|
export type SelectionAnchor = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -50,7 +50,7 @@ export function applyShiftRangeSelection<T>(
|
|||||||
: [currentIndex, anchorIndex];
|
: [currentIndex, anchorIndex];
|
||||||
|
|
||||||
const selected = anchorRef.current.selected;
|
const selected = anchorRef.current.selected;
|
||||||
table.setRowSelection((currentSelection) => {
|
table.setRowSelection((currentSelection: RowSelectionState) => {
|
||||||
const nextSelection = { ...currentSelection };
|
const nextSelection = { ...currentSelection };
|
||||||
|
|
||||||
for (let index = from; index <= to; index++) {
|
for (let index = from; index <= to; index++) {
|
||||||
33
src/client/components/table/url.test.ts
Normal file
33
src/client/components/table/url.test.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatUrlForDisplay, resolveUrlHref } from "./url";
|
||||||
|
|
||||||
|
describe("table URL helpers", () => {
|
||||||
|
it("formats URLs without scroll-to-text fragments", () => {
|
||||||
|
expect(
|
||||||
|
formatUrlForDisplay("https://example.com/a%20b?q=one#:~:text=needle"),
|
||||||
|
).toBe("https://example.com/a b?q=one");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps normal hashes and query strings in display labels", () => {
|
||||||
|
expect(formatUrlForDisplay("https://example.com/path?q=1#section")).toBe(
|
||||||
|
"https://example.com/path?q=1#section",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to raw display text for invalid URLs", () => {
|
||||||
|
expect(formatUrlForDisplay("/relative/path")).toBe("/relative/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves relative URLs against a base domain", () => {
|
||||||
|
expect(resolveUrlHref("/pricing", "example.com")).toBe(
|
||||||
|
"https://example.com/pricing",
|
||||||
|
);
|
||||||
|
expect(resolveUrlHref("docs", "example.com")).toBe(
|
||||||
|
"https://example.com/docs",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsafe absolute URL schemes", () => {
|
||||||
|
expect(resolveUrlHref("javascript:alert(1)", "example.com")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
85
src/client/components/table/url.tsx
Normal file
85
src/client/components/table/url.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
export function formatUrlForDisplay(value: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
const hash = url.hash.startsWith("#:~:") ? "" : url.hash;
|
||||||
|
const cleaned = `${url.protocol}//${url.host}${url.pathname}${url.search}${hash}`;
|
||||||
|
try {
|
||||||
|
return decodeURI(cleaned);
|
||||||
|
} catch {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUrlHref(
|
||||||
|
value: string | null | undefined,
|
||||||
|
baseDomain?: string,
|
||||||
|
): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)) {
|
||||||
|
return getSafeExternalUrl(value);
|
||||||
|
}
|
||||||
|
if (!baseDomain) return null;
|
||||||
|
return getSafeExternalUrl(
|
||||||
|
`https://${baseDomain}${value.startsWith("/") ? value : `/${value}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalUrlCell({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
baseDomain,
|
||||||
|
className = "link link-primary inline-flex items-center gap-1",
|
||||||
|
display = "formatted",
|
||||||
|
empty = "-",
|
||||||
|
}: {
|
||||||
|
value: string | null | undefined;
|
||||||
|
label?: string | null;
|
||||||
|
baseDomain?: string;
|
||||||
|
className?: string;
|
||||||
|
display?: "formatted" | "path" | "raw";
|
||||||
|
empty?: string;
|
||||||
|
}) {
|
||||||
|
const href = resolveUrlHref(value, baseDomain);
|
||||||
|
if (!value || !href) {
|
||||||
|
return <span className="text-base-content/40">{empty}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleLabel = label ?? getUrlDisplayLabel(value, display);
|
||||||
|
return (
|
||||||
|
<a className={className} href={href} target="_blank" rel="noreferrer">
|
||||||
|
<span className="truncate">{visibleLabel}</span>
|
||||||
|
<ExternalLink className="size-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrlDisplayLabel(
|
||||||
|
value: string,
|
||||||
|
display: "formatted" | "path" | "raw",
|
||||||
|
) {
|
||||||
|
if (display === "raw") return value;
|
||||||
|
if (display === "path") {
|
||||||
|
try {
|
||||||
|
return new URL(value).pathname;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatUrlForDisplay(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeExternalUrl(value: string) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||||
|
? parsed.toString()
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,13 @@
|
|||||||
import {
|
import { createColumnHelper, type Table } from "@tanstack/react-table";
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
|
||||||
type Table,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { AppDataTable } from "@/client/components/table/AppDataTable";
|
||||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
import { numericNullsLast } from "@/client/components/table/nullSafeSort";
|
import { numericNullsLast } from "@/client/components/table/nullSafeSort";
|
||||||
import {
|
import {
|
||||||
formatCount,
|
formatCount,
|
||||||
formatPlatformLabel,
|
formatPlatformLabel,
|
||||||
} from "@/client/features/ai-search/platformLabels";
|
} from "@/client/features/ai-search/platformLabels";
|
||||||
import { formatUrlForDisplay } from "@/client/features/ai-search/urlDisplay";
|
import { formatUrlForDisplay } from "@/client/components/table/url";
|
||||||
import type { BrandLookupResult } from "@/types/schemas/ai-search";
|
import type { BrandLookupResult } from "@/types/schemas/ai-search";
|
||||||
|
|
||||||
type TopPageRow = BrandLookupResult["topPages"][number];
|
type TopPageRow = BrandLookupResult["topPages"][number];
|
||||||
@ -143,55 +140,17 @@ function BrandLookupTable<T>({
|
|||||||
urlLikeColumnId: string;
|
urlLikeColumnId: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<AppDataTable
|
||||||
<table className="table table-sm">
|
table={table}
|
||||||
<thead>
|
getCellClassName={(_, columnId) =>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
cellClassName(
|
||||||
<tr key={headerGroup.id}>
|
columnId,
|
||||||
{headerGroup.headers.map((header) => {
|
urlLikeColumnId,
|
||||||
const isNumeric = header.column.getCanSort();
|
table.getColumn(columnId)?.getCanSort() ?? false,
|
||||||
return (
|
)
|
||||||
<th
|
}
|
||||||
key={header.id}
|
getRowClassName={() => ""}
|
||||||
className={`text-xs uppercase tracking-wider text-base-content/60 ${
|
/>
|
||||||
isNumeric ? "text-right" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr key={row.id}>
|
|
||||||
{row.getVisibleCells().map((cell) => {
|
|
||||||
const isNumeric = cell.column.getCanSort();
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
key={cell.id}
|
|
||||||
className={cellClassName(
|
|
||||||
cell.column.id,
|
|
||||||
urlLikeColumnId,
|
|
||||||
isNumeric,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import { type SortingState } from "@tanstack/react-table";
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type SortingState,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { Download, Info, SlidersHorizontal } from "lucide-react";
|
import { Download, Info, SlidersHorizontal } from "lucide-react";
|
||||||
|
import { useAppTable } from "@/client/components/table/AppDataTable";
|
||||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
||||||
import {
|
import {
|
||||||
buildBrandLookupExport,
|
buildBrandLookupExport,
|
||||||
@ -251,21 +247,19 @@ function CitationTabsCard({ result }: { result: BrandLookupResult }) {
|
|||||||
[result.topQueries, filters.queries.values],
|
[result.topQueries, filters.queries.values],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pagesTable = useReactTable({
|
const pagesTable = useAppTable({
|
||||||
data: filteredPages,
|
data: filteredPages,
|
||||||
columns: topPagesColumns,
|
columns: topPagesColumns,
|
||||||
state: { sorting: pagesSort },
|
state: { sorting: pagesSort },
|
||||||
onSortingChange: setPagesSort,
|
onSortingChange: setPagesSort,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
withSorting: true,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
});
|
});
|
||||||
const queriesTable = useReactTable({
|
const queriesTable = useAppTable({
|
||||||
data: filteredQueries,
|
data: filteredQueries,
|
||||||
columns: topQueriesColumns,
|
columns: topQueriesColumns,
|
||||||
state: { sorting: queriesSort },
|
state: { sorting: queriesSort },
|
||||||
onSortingChange: setQueriesSort,
|
onSortingChange: setQueriesSort,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
withSorting: true,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
|
// Not memoized: TanStack's `getSortedRowModel()` is internally cached, and
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
formatModelLabel,
|
formatModelLabel,
|
||||||
getModelAccent,
|
getModelAccent,
|
||||||
} from "@/client/features/ai-search/platformLabels";
|
} from "@/client/features/ai-search/platformLabels";
|
||||||
import { formatUrlForDisplay } from "@/client/features/ai-search/urlDisplay";
|
import { formatUrlForDisplay } from "@/client/components/table/url";
|
||||||
import type {
|
import type {
|
||||||
PromptExplorerModelResult,
|
PromptExplorerModelResult,
|
||||||
PromptExplorerResult,
|
PromptExplorerResult,
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Prettify a URL for display: drop Chrome scroll-to-text fragments (`#:~:`)
|
|
||||||
* and decode percent-encoding so `%20` becomes a space. Google AI Overview
|
|
||||||
* citations routinely carry 200-char text fragments that are useful in the
|
|
||||||
* href (they scroll the browser to the cited passage) but pure visual noise
|
|
||||||
* as link text.
|
|
||||||
*
|
|
||||||
* The original URL is still what gets navigated to — only the visible text
|
|
||||||
* changes. Falls back to the raw input if parsing fails.
|
|
||||||
*/
|
|
||||||
export function formatUrlForDisplay(value: string): string {
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
const hash = url.hash.startsWith("#:~:") ? "" : url.hash;
|
|
||||||
const cleaned = `${url.protocol}//${url.host}${url.pathname}${url.search}${hash}`;
|
|
||||||
try {
|
|
||||||
return decodeURI(cleaned);
|
|
||||||
} catch {
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +1,8 @@
|
|||||||
import {
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getExpandedRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
useAppTable,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
import { EmptyTableState } from "./BacklinksPageEmptyTableState";
|
||||||
import { backlinksColumns } from "./BacklinksTableColumns";
|
import { backlinksColumns } from "./BacklinksTableColumns";
|
||||||
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
import type { BacklinksOverviewData } from "./backlinksPageTypes";
|
||||||
@ -18,16 +15,15 @@ export function BacklinksTable({
|
|||||||
}) {
|
}) {
|
||||||
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
|
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useAppTable({
|
||||||
data: groupedData,
|
data: groupedData,
|
||||||
columns: backlinksColumns,
|
columns: backlinksColumns,
|
||||||
initialState: {
|
initialState: {
|
||||||
sorting: [{ id: "firstSeen", desc: true }],
|
sorting: [{ id: "firstSeen", desc: true }],
|
||||||
},
|
},
|
||||||
getSubRows: (row) => row.subRows,
|
getSubRows: (row) => row.subRows,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
withSorting: true,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
withExpanded: true,
|
||||||
getExpandedRowModel: getExpandedRowModel(),
|
|
||||||
getRowCanExpand: (row) => row.depth === 0,
|
getRowCanExpand: (row) => row.depth === 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -36,51 +32,16 @@ export function BacklinksTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<AppDataTable
|
||||||
<table className="table table-sm" style={{ tableLayout: "fixed" }}>
|
table={table}
|
||||||
<colgroup>
|
fixedLayout
|
||||||
{table.getVisibleLeafColumns().map((column) => (
|
getRowProps={(row) => ({
|
||||||
<col key={column.id} style={{ width: column.getSize() }} />
|
className:
|
||||||
))}
|
row.depth === 0
|
||||||
</colgroup>
|
? "cursor-pointer bg-base-200/50 transition-colors hover:bg-base-200/80"
|
||||||
<thead>
|
: "bg-base-100",
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
onClick: row.depth === 0 ? row.getToggleExpandedHandler() : undefined,
|
||||||
<tr key={headerGroup.id}>
|
})}
|
||||||
{headerGroup.headers.map((header) => (
|
/>
|
||||||
<th key={header.id} style={{ width: header.getSize() }}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
className={
|
|
||||||
row.depth === 0
|
|
||||||
? "cursor-pointer bg-base-200/50 transition-colors hover:bg-base-200/80"
|
|
||||||
: "bg-base-100"
|
|
||||||
}
|
|
||||||
onClick={
|
|
||||||
row.depth === 0 ? row.getToggleExpandedHandler() : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type SortingFn,
|
type SortingFn,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
useAppTable,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
import {
|
import {
|
||||||
compareNumericNullsLast,
|
compareNumericNullsLast,
|
||||||
@ -150,13 +150,12 @@ export function ReferringDomainsTable({
|
|||||||
}) {
|
}) {
|
||||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useAppTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
state: { sorting },
|
state: { sorting },
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
withSorting: true,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@ -164,43 +163,11 @@ export function ReferringDomainsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<AppDataTable
|
||||||
<table className="table table-sm">
|
table={table}
|
||||||
<thead>
|
getCellClassName={(_, columnId) =>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
columnId === "domain" ? "font-medium break-all" : undefined
|
||||||
<tr key={headerGroup.id}>
|
}
|
||||||
{headerGroup.headers.map((header) => (
|
/>
|
||||||
<th key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr key={row.id}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td
|
|
||||||
key={cell.id}
|
|
||||||
className={
|
|
||||||
cell.column.id === "domain"
|
|
||||||
? "font-medium break-all"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
import {
|
import { createColumnHelper, type SortingState } from "@tanstack/react-table";
|
||||||
createColumnHelper,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type SortingState,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
useAppTable,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
import {
|
import {
|
||||||
numericNullsLast,
|
numericNullsLast,
|
||||||
@ -103,13 +100,12 @@ export function TopPagesTable({
|
|||||||
}) {
|
}) {
|
||||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useAppTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
state: { sorting },
|
state: { sorting },
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
withSorting: true,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@ -117,39 +113,11 @@ export function TopPagesTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<AppDataTable
|
||||||
<table className="table table-sm">
|
table={table}
|
||||||
<thead>
|
getCellClassName={(_, columnId) =>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
columnId === "page" ? "min-w-80" : undefined
|
||||||
<tr key={headerGroup.id}>
|
}
|
||||||
{headerGroup.headers.map((header) => (
|
/>
|
||||||
<th key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr key={row.id}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td
|
|
||||||
key={cell.id}
|
|
||||||
className={cell.column.id === "page" ? "min-w-80" : undefined}
|
|
||||||
>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,7 +150,6 @@ export function DomainOverviewPage({
|
|||||||
canSaveKeywords={state.canSaveKeywords}
|
canSaveKeywords={state.canSaveKeywords}
|
||||||
onSortClick={state.handleSortColumnClick}
|
onSortClick={state.handleSortColumnClick}
|
||||||
onToggleKeyword={state.toggleKeywordSelection}
|
onToggleKeyword={state.toggleKeywordSelection}
|
||||||
onToggleAllVisible={state.toggleAllVisibleKeywords}
|
|
||||||
page={state.page}
|
page={state.page}
|
||||||
pageSize={state.pageSize}
|
pageSize={state.pageSize}
|
||||||
totalKeywordCount={state.totalKeywordCount}
|
totalKeywordCount={state.totalKeywordCount}
|
||||||
|
|||||||
@ -1,11 +1,19 @@
|
|||||||
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
type ColumnDef,
|
||||||
|
type RowSelectionState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
makeSelectionColumn,
|
||||||
|
useAppTable,
|
||||||
|
useSelectionAnchor,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
|
import { ExternalUrlCell } from "@/client/components/table/url";
|
||||||
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 {
|
import { formatFloat, formatNumber } from "@/client/features/domain/utils";
|
||||||
formatFloat,
|
|
||||||
formatNumber,
|
|
||||||
resolveDomainPageHref,
|
|
||||||
} from "@/client/features/domain/utils";
|
|
||||||
import type {
|
import type {
|
||||||
DomainSortMode,
|
DomainSortMode,
|
||||||
KeywordRow,
|
KeywordRow,
|
||||||
@ -21,9 +29,10 @@ type Props = {
|
|||||||
currentSortOrder: SortOrder;
|
currentSortOrder: SortOrder;
|
||||||
onSortClick: (sort: DomainSortMode) => void;
|
onSortClick: (sort: DomainSortMode) => void;
|
||||||
onToggleKeyword: (keyword: string) => void;
|
onToggleKeyword: (keyword: string) => void;
|
||||||
onToggleAllVisible: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const keywordColumnHelper = createColumnHelper<KeywordRow>();
|
||||||
|
|
||||||
export function DomainKeywordsTable({
|
export function DomainKeywordsTable({
|
||||||
domain,
|
domain,
|
||||||
rows,
|
rows,
|
||||||
@ -33,8 +42,122 @@ export function DomainKeywordsTable({
|
|||||||
currentSortOrder,
|
currentSortOrder,
|
||||||
onSortClick,
|
onSortClick,
|
||||||
onToggleKeyword,
|
onToggleKeyword,
|
||||||
onToggleAllVisible,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const selectAnchorRef = useSelectionAnchor();
|
||||||
|
const rowSelection = useMemo<RowSelectionState>(
|
||||||
|
() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
[...selectedKeywords].map((keyword) => [keyword, true]),
|
||||||
|
) as RowSelectionState,
|
||||||
|
[selectedKeywords],
|
||||||
|
);
|
||||||
|
const columns = useMemo<ColumnDef<KeywordRow>[]>(
|
||||||
|
() => [
|
||||||
|
makeSelectionColumn<KeywordRow>(selectAnchorRef),
|
||||||
|
keywordColumnHelper.accessor("keyword", {
|
||||||
|
header: () => "Keyword",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-medium">{getValue()}</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("position", {
|
||||||
|
header: () => (
|
||||||
|
<SortableHeader
|
||||||
|
label="Rank"
|
||||||
|
isActive={sortMode === "rank"}
|
||||||
|
order={currentSortOrder}
|
||||||
|
onClick={() => onSortClick("rank")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => getValue() ?? "-",
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("searchVolume", {
|
||||||
|
header: () => (
|
||||||
|
<SortableHeader
|
||||||
|
label="Volume"
|
||||||
|
isActive={sortMode === "volume"}
|
||||||
|
order={currentSortOrder}
|
||||||
|
onClick={() => onSortClick("volume")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("traffic", {
|
||||||
|
header: () => (
|
||||||
|
<SortableHeader
|
||||||
|
label="Traffic"
|
||||||
|
isActive={sortMode === "traffic"}
|
||||||
|
order={currentSortOrder}
|
||||||
|
onClick={() => onSortClick("traffic")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatFloat(getValue()),
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("cpc", {
|
||||||
|
header: () => (
|
||||||
|
<SortableHeader
|
||||||
|
label="CPC"
|
||||||
|
helpText="Cost per click in USD."
|
||||||
|
isActive={sortMode === "cpc"}
|
||||||
|
order={currentSortOrder}
|
||||||
|
onClick={() => onSortClick("cpc")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return value == null ? "-" : `$${value.toFixed(2)}`;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.display({
|
||||||
|
id: "url",
|
||||||
|
header: () => "URL",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ExternalUrlCell
|
||||||
|
value={row.original.relativeUrl ?? row.original.url}
|
||||||
|
label={row.original.relativeUrl ?? row.original.url ?? ""}
|
||||||
|
baseDomain={domain}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
cellClassName: "max-w-[260px] truncate",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("keywordDifficulty", {
|
||||||
|
header: () => (
|
||||||
|
<SortableHeader
|
||||||
|
label="Score"
|
||||||
|
helpText="Keyword difficulty score."
|
||||||
|
isActive={sortMode === "score"}
|
||||||
|
order={currentSortOrder}
|
||||||
|
onClick={() => onSortClick("score")}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => <DifficultyBadge value={getValue()} />,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[currentSortOrder, domain, onSortClick, selectAnchorRef, sortMode],
|
||||||
|
);
|
||||||
|
const table = useAppTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
state: { rowSelection },
|
||||||
|
onRowSelectionChange: (updater) => {
|
||||||
|
const next =
|
||||||
|
typeof updater === "function" ? updater(rowSelection) : updater;
|
||||||
|
const selected = Object.entries(next)
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([keyword]) => keyword);
|
||||||
|
for (const keyword of visibleKeywords) {
|
||||||
|
const shouldBeSelected = selected.includes(keyword);
|
||||||
|
if (selectedKeywords.has(keyword) !== shouldBeSelected) {
|
||||||
|
onToggleKeyword(keyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getRowId: (row) => row.keyword,
|
||||||
|
enableRowSelection: true,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="mb-2 text-xs text-base-content/60">
|
<div className="mb-2 text-xs text-base-content/60">
|
||||||
@ -42,121 +165,16 @@ export function DomainKeywordsTable({
|
|||||||
? `${selectedKeywords.size} selected`
|
? `${selectedKeywords.size} selected`
|
||||||
: "Select keywords to save"}
|
: "Select keywords to save"}
|
||||||
</div>
|
</div>
|
||||||
<table className="table table-zebra table-sm">
|
<AppDataTable
|
||||||
<thead>
|
table={table}
|
||||||
<tr>
|
className="table table-zebra table-sm"
|
||||||
<th>
|
wrapperClassName=""
|
||||||
<input
|
empty={
|
||||||
type="checkbox"
|
<div className="py-6 text-center text-base-content/60">
|
||||||
className="checkbox checkbox-xs"
|
No keywords match this search.
|
||||||
checked={
|
</div>
|
||||||
visibleKeywords.length > 0 &&
|
}
|
||||||
visibleKeywords.every((keyword) =>
|
/>
|
||||||
selectedKeywords.has(keyword),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onChange={onToggleAllVisible}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>Keyword</th>
|
|
||||||
<th>
|
|
||||||
<SortableHeader
|
|
||||||
label="Rank"
|
|
||||||
isActive={sortMode === "rank"}
|
|
||||||
order={currentSortOrder}
|
|
||||||
onClick={() => onSortClick("rank")}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<SortableHeader
|
|
||||||
label="Volume"
|
|
||||||
isActive={sortMode === "volume"}
|
|
||||||
order={currentSortOrder}
|
|
||||||
onClick={() => onSortClick("volume")}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<SortableHeader
|
|
||||||
label="Traffic"
|
|
||||||
isActive={sortMode === "traffic"}
|
|
||||||
order={currentSortOrder}
|
|
||||||
onClick={() => onSortClick("traffic")}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<SortableHeader
|
|
||||||
label="CPC"
|
|
||||||
helpText="Cost per click in USD."
|
|
||||||
isActive={sortMode === "cpc"}
|
|
||||||
order={currentSortOrder}
|
|
||||||
onClick={() => onSortClick("cpc")}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th>URL</th>
|
|
||||||
<th>
|
|
||||||
<SortableHeader
|
|
||||||
label="Score"
|
|
||||||
helpText="Keyword difficulty score."
|
|
||||||
isActive={sortMode === "score"}
|
|
||||||
order={currentSortOrder}
|
|
||||||
onClick={() => onSortClick("score")}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="py-6 text-center text-base-content/60">
|
|
||||||
No keywords match this search.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
rows.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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { SafeExternalLink } from "@/client/components/SafeExternalLink";
|
import { useMemo } from "react";
|
||||||
|
import { createColumnHelper, type ColumnDef } from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
useAppTable,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
|
import { ExternalUrlCell } from "@/client/components/table/url";
|
||||||
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 {
|
||||||
@ -20,6 +25,8 @@ type Props = {
|
|||||||
onSortClick: (sort: DomainSortMode) => void;
|
onSortClick: (sort: DomainSortMode) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pageColumnHelper = createColumnHelper<PageRow>();
|
||||||
|
|
||||||
export function DomainPagesTable({
|
export function DomainPagesTable({
|
||||||
domain,
|
domain,
|
||||||
rows,
|
rows,
|
||||||
@ -27,65 +34,62 @@ export function DomainPagesTable({
|
|||||||
currentSortOrder,
|
currentSortOrder,
|
||||||
onSortClick,
|
onSortClick,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
const columns = useMemo<ColumnDef<PageRow>[]>(
|
||||||
<div className="overflow-x-auto">
|
() => [
|
||||||
<table className="table table-zebra table-sm">
|
pageColumnHelper.display({
|
||||||
<thead>
|
id: "page",
|
||||||
<tr>
|
header: () => "Page",
|
||||||
<th>Page</th>
|
cell: ({ row }) => (
|
||||||
<th>
|
<ExternalUrlCell
|
||||||
<SortableHeader
|
value={row.original.relativePath ?? row.original.page}
|
||||||
label="Organic Traffic"
|
label={row.original.relativePath ?? row.original.page}
|
||||||
isActive={toPageSortMode(sortMode) === "traffic"}
|
baseDomain={domain}
|
||||||
order={currentSortOrder}
|
className="link link-primary inline-flex items-center gap-1"
|
||||||
onClick={() => onSortClick("traffic")}
|
/>
|
||||||
/>
|
),
|
||||||
</th>
|
meta: {
|
||||||
<th>
|
cellClassName: "max-w-[420px] truncate",
|
||||||
<SortableHeader
|
},
|
||||||
label="Keywords"
|
}),
|
||||||
isActive={toPageSortMode(sortMode) === "keywords"}
|
pageColumnHelper.accessor("organicTraffic", {
|
||||||
order={currentSortOrder}
|
header: () => (
|
||||||
onClick={() => onSortClick("volume")}
|
<SortableHeader
|
||||||
/>
|
label="Organic Traffic"
|
||||||
</th>
|
isActive={toPageSortMode(sortMode) === "traffic"}
|
||||||
</tr>
|
order={currentSortOrder}
|
||||||
</thead>
|
onClick={() => onSortClick("traffic")}
|
||||||
<tbody>
|
/>
|
||||||
{rows.length === 0 ? (
|
),
|
||||||
<tr>
|
cell: ({ getValue }) => formatFloat(getValue()),
|
||||||
<td colSpan={3} className="py-6 text-center text-base-content/60">
|
}),
|
||||||
No pages match this search.
|
pageColumnHelper.accessor("keywords", {
|
||||||
</td>
|
header: () => (
|
||||||
</tr>
|
<SortableHeader
|
||||||
) : (
|
label="Keywords"
|
||||||
rows.slice(0, 100).map((row) => {
|
isActive={toPageSortMode(sortMode) === "keywords"}
|
||||||
const href = resolveDomainPageHref(
|
order={currentSortOrder}
|
||||||
row.relativePath ?? row.page,
|
onClick={() => onSortClick("volume")}
|
||||||
domain,
|
/>
|
||||||
);
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[currentSortOrder, domain, onSortClick, sortMode],
|
||||||
|
);
|
||||||
|
const table = useAppTable({
|
||||||
|
data: rows.slice(0, 100),
|
||||||
|
columns,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={row.page}>
|
<AppDataTable
|
||||||
<td className="max-w-[420px] truncate" title={row.page}>
|
table={table}
|
||||||
{href ? (
|
className="table table-zebra table-sm"
|
||||||
<SafeExternalLink
|
empty={
|
||||||
url={href}
|
<div className="py-6 text-center text-base-content/60">
|
||||||
label={row.relativePath ?? row.page}
|
No pages match this search.
|
||||||
className="link link-primary inline-flex items-center gap-1"
|
</div>
|
||||||
/>
|
}
|
||||||
) : (
|
/>
|
||||||
(row.relativePath ?? row.page)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{formatFloat(row.organicTraffic)}</td>
|
|
||||||
<td>{formatNumber(row.keywords)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,7 +59,6 @@ type Props = {
|
|||||||
canSaveKeywords: boolean;
|
canSaveKeywords: boolean;
|
||||||
onSortClick: (sort: DomainSortMode) => void;
|
onSortClick: (sort: DomainSortMode) => void;
|
||||||
onToggleKeyword: (keyword: string) => void;
|
onToggleKeyword: (keyword: string) => void;
|
||||||
onToggleAllVisible: () => void;
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
totalKeywordCount: number | null;
|
totalKeywordCount: number | null;
|
||||||
@ -104,7 +103,6 @@ export function DomainResultsCard({
|
|||||||
canSaveKeywords,
|
canSaveKeywords,
|
||||||
onSortClick,
|
onSortClick,
|
||||||
onToggleKeyword,
|
onToggleKeyword,
|
||||||
onToggleAllVisible,
|
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
totalKeywordCount,
|
totalKeywordCount,
|
||||||
@ -320,7 +318,6 @@ export function DomainResultsCard({
|
|||||||
currentSortOrder={currentSortOrder}
|
currentSortOrder={currentSortOrder}
|
||||||
onSortClick={onSortClick}
|
onSortClick={onSortClick}
|
||||||
onToggleKeyword={onToggleKeyword}
|
onToggleKeyword={onToggleKeyword}
|
||||||
onToggleAllVisible={onToggleAllVisible}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -117,14 +117,3 @@ export function pagesToTable(rows: PageRow[]): ExportTable {
|
|||||||
rows: rows.map((row) => [row.page, row.organicTraffic, row.keywords]),
|
rows: rows.map((row) => [row.page, row.organicTraffic, row.keywords]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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}`}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -47,61 +47,6 @@ export function OverviewStats({ keyword }: { keyword: KeywordResearchRow }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeywordRow({
|
|
||||||
row,
|
|
||||||
isSelected,
|
|
||||||
isActive,
|
|
||||||
onToggle,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
row: KeywordResearchRow;
|
|
||||||
isSelected: boolean;
|
|
||||||
isActive: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-3 px-4 py-2 border-b border-base-200 text-sm hover:bg-base-200/50 transition-colors cursor-pointer ${
|
|
||||||
isActive ? "bg-primary/5 border-l-2 border-l-primary" : ""
|
|
||||||
}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-xs shrink-0"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggle();
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span
|
|
||||||
className="flex-1 min-w-0 truncate font-medium capitalize"
|
|
||||||
title={row.keyword}
|
|
||||||
>
|
|
||||||
{row.keyword}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="w-16 text-right tabular-nums text-base-content/70">
|
|
||||||
{formatNumber(row.searchVolume)}
|
|
||||||
</span>
|
|
||||||
<span className="w-14 text-right tabular-nums text-base-content/70">
|
|
||||||
{row.cpc == null ? "-" : row.cpc.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
<span className="w-12 text-right tabular-nums text-base-content/70">
|
|
||||||
{row.competition == null ? "-" : row.competition.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="w-10 flex justify-end">
|
|
||||||
<ScoreBadge value={row.keywordDifficulty} size="sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeywordCard({
|
export function KeywordCard({
|
||||||
row,
|
row,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
|||||||
@ -12,18 +12,16 @@ import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywo
|
|||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
import {
|
import {
|
||||||
AreaTrendChart,
|
AreaTrendChart,
|
||||||
KeywordRow,
|
|
||||||
OverviewStats,
|
OverviewStats,
|
||||||
SerpAnalysisCard,
|
SerpAnalysisCard,
|
||||||
SortHeader,
|
|
||||||
} from "@/client/features/keywords/components";
|
} from "@/client/features/keywords/components";
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
import type { KeywordResearchControllerState } from "./types";
|
||||||
import {
|
import {
|
||||||
EmptyFilterResults,
|
|
||||||
FilterRangeInputs,
|
FilterRangeInputs,
|
||||||
FilterTextInput,
|
FilterTextInput,
|
||||||
} from "./keywordResearchDesktopFilters";
|
} from "./keywordResearchDesktopFilters";
|
||||||
|
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
||||||
|
|
||||||
const MONTH_SHORT_LABELS = [
|
const MONTH_SHORT_LABELS = [
|
||||||
"Jan",
|
"Jan",
|
||||||
@ -192,8 +190,18 @@ function DesktopTableCard({ controller }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showFilters ? <DesktopFilters controller={controller} /> : null}
|
{showFilters ? <DesktopFilters controller={controller} /> : null}
|
||||||
<DesktopTableHeader controller={controller} />
|
<KeywordResearchDesktopTable
|
||||||
<DesktopTableRows controller={controller} />
|
activeFilterCount={controller.activeFilterCount}
|
||||||
|
filteredRows={controller.filteredRows}
|
||||||
|
overviewKeyword={controller.overviewKeyword}
|
||||||
|
selectedRows={controller.selectedRows}
|
||||||
|
setSelectedRows={controller.setSelectedRows}
|
||||||
|
sortDir={controller.sortDir}
|
||||||
|
sortField={controller.sortField}
|
||||||
|
toggleSort={controller.toggleSort}
|
||||||
|
resetFilters={controller.resetFilters}
|
||||||
|
handleRowClick={controller.handleRowClick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -262,93 +270,6 @@ function DesktopFilters({ controller }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DesktopTableHeader({ controller }: Props) {
|
|
||||||
const { filteredRows, selectedRows } = controller;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shrink-0 flex items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100 text-xs text-base-content/60 font-medium">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-xs shrink-0"
|
|
||||||
checked={
|
|
||||||
filteredRows.length > 0 && selectedRows.size === filteredRows.length
|
|
||||||
}
|
|
||||||
onChange={controller.toggleAllRows}
|
|
||||||
/>
|
|
||||||
<SortHeader
|
|
||||||
label="Keyword"
|
|
||||||
field="keyword"
|
|
||||||
current={controller.sortField}
|
|
||||||
dir={controller.sortDir}
|
|
||||||
onToggle={controller.toggleSort}
|
|
||||||
className="flex-1 min-w-0"
|
|
||||||
/>
|
|
||||||
<SortHeader
|
|
||||||
label="Volume"
|
|
||||||
field="searchVolume"
|
|
||||||
current={controller.sortField}
|
|
||||||
dir={controller.sortDir}
|
|
||||||
onToggle={controller.toggleSort}
|
|
||||||
className="w-16 text-right"
|
|
||||||
/>
|
|
||||||
<SortHeader
|
|
||||||
label="CPC"
|
|
||||||
helpText="Cost per click in USD."
|
|
||||||
field="cpc"
|
|
||||||
current={controller.sortField}
|
|
||||||
dir={controller.sortDir}
|
|
||||||
onToggle={controller.toggleSort}
|
|
||||||
className="w-14 text-right"
|
|
||||||
/>
|
|
||||||
<SortHeader
|
|
||||||
label="Comp."
|
|
||||||
helpText="Advertiser competition."
|
|
||||||
field="competition"
|
|
||||||
current={controller.sortField}
|
|
||||||
dir={controller.sortDir}
|
|
||||||
onToggle={controller.toggleSort}
|
|
||||||
className="w-12 text-right"
|
|
||||||
/>
|
|
||||||
<SortHeader
|
|
||||||
label="Score"
|
|
||||||
helpText="Keyword difficulty score."
|
|
||||||
field="keywordDifficulty"
|
|
||||||
current={controller.sortField}
|
|
||||||
dir={controller.sortDir}
|
|
||||||
onToggle={controller.toggleSort}
|
|
||||||
className="w-10 text-right"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopTableRows({ controller }: Props) {
|
|
||||||
const { activeFilterCount, filteredRows, overviewKeyword, selectedRows } =
|
|
||||||
controller;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{filteredRows.length === 0 ? (
|
|
||||||
<EmptyFilterResults
|
|
||||||
activeFilterCount={activeFilterCount}
|
|
||||||
resetFilters={controller.resetFilters}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
filteredRows.map((row) => (
|
|
||||||
<KeywordRow
|
|
||||||
key={row.keyword}
|
|
||||||
row={row}
|
|
||||||
isSelected={selectedRows.has(row.keyword)}
|
|
||||||
isActive={overviewKeyword?.keyword === row.keyword}
|
|
||||||
onToggle={() => controller.toggleRowSelection(row.keyword)}
|
|
||||||
onClick={() => controller.handleRowClick(row)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopSerpPanel({ controller }: Props) {
|
function DesktopSerpPanel({ controller }: Props) {
|
||||||
const { overviewKeyword } = controller;
|
const { overviewKeyword } = controller;
|
||||||
const trendRangeLabel = overviewKeyword
|
const trendRangeLabel = overviewKeyword
|
||||||
|
|||||||
@ -0,0 +1,215 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
type ColumnDef,
|
||||||
|
type RowSelectionState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
makeSelectionColumn,
|
||||||
|
useAppTable,
|
||||||
|
useSelectionAnchor,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
|
import {
|
||||||
|
SortHeader,
|
||||||
|
type SortDir,
|
||||||
|
type SortField,
|
||||||
|
} from "@/client/features/keywords/components";
|
||||||
|
import { formatNumber } from "@/client/features/keywords/utils";
|
||||||
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
|
import { EmptyFilterResults } from "./keywordResearchDesktopFilters";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
activeFilterCount: number;
|
||||||
|
filteredRows: KeywordResearchRow[];
|
||||||
|
overviewKeyword: KeywordResearchRow | null;
|
||||||
|
selectedRows: Set<string>;
|
||||||
|
setSelectedRows: (rows: Set<string>) => void;
|
||||||
|
sortDir: SortDir;
|
||||||
|
sortField: SortField;
|
||||||
|
toggleSort: (field: SortField) => void;
|
||||||
|
resetFilters: () => void;
|
||||||
|
handleRowClick: (row: KeywordResearchRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keywordColumnHelper = createColumnHelper<KeywordResearchRow>();
|
||||||
|
|
||||||
|
export function KeywordResearchDesktopTable({
|
||||||
|
activeFilterCount,
|
||||||
|
filteredRows,
|
||||||
|
overviewKeyword,
|
||||||
|
selectedRows,
|
||||||
|
setSelectedRows,
|
||||||
|
sortDir,
|
||||||
|
sortField,
|
||||||
|
toggleSort,
|
||||||
|
resetFilters,
|
||||||
|
handleRowClick,
|
||||||
|
}: Props) {
|
||||||
|
const selectAnchorRef = useSelectionAnchor();
|
||||||
|
const rowSelection = useMemo<RowSelectionState>(
|
||||||
|
() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
[...selectedRows].map((keyword) => [keyword, true]),
|
||||||
|
) as RowSelectionState,
|
||||||
|
[selectedRows],
|
||||||
|
);
|
||||||
|
const columns = useMemo<ColumnDef<KeywordResearchRow>[]>(
|
||||||
|
() => [
|
||||||
|
makeSelectionColumn<KeywordResearchRow>(selectAnchorRef),
|
||||||
|
keywordColumnHelper.accessor("keyword", {
|
||||||
|
header: () => (
|
||||||
|
<SortHeader
|
||||||
|
label="Keyword"
|
||||||
|
field="keyword"
|
||||||
|
current={sortField}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={toggleSort}
|
||||||
|
className="min-w-0"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className="block truncate font-medium capitalize"
|
||||||
|
title={row.original.keyword}
|
||||||
|
>
|
||||||
|
{row.original.keyword}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { cellClassName: "min-w-0" },
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("searchVolume", {
|
||||||
|
header: () => (
|
||||||
|
<SortHeader
|
||||||
|
label="Volume"
|
||||||
|
field="searchVolume"
|
||||||
|
current={sortField}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={toggleSort}
|
||||||
|
className="justify-end"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatNumber(getValue()),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "text-right",
|
||||||
|
cellClassName: "text-right tabular-nums text-base-content/70",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("cpc", {
|
||||||
|
header: () => (
|
||||||
|
<SortHeader
|
||||||
|
label="CPC"
|
||||||
|
helpText="Cost per click in USD."
|
||||||
|
field="cpc"
|
||||||
|
current={sortField}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={toggleSort}
|
||||||
|
className="justify-end"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return value == null ? "-" : value.toFixed(2);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: "text-right",
|
||||||
|
cellClassName: "text-right tabular-nums text-base-content/70",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("competition", {
|
||||||
|
header: () => (
|
||||||
|
<SortHeader
|
||||||
|
label="Comp."
|
||||||
|
helpText="Advertiser competition."
|
||||||
|
field="competition"
|
||||||
|
current={sortField}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={toggleSort}
|
||||||
|
className="justify-end"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return value == null ? "-" : value.toFixed(2);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
headerClassName: "text-right",
|
||||||
|
cellClassName: "text-right tabular-nums text-base-content/70",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
keywordColumnHelper.accessor("keywordDifficulty", {
|
||||||
|
header: () => (
|
||||||
|
<SortHeader
|
||||||
|
label="Score"
|
||||||
|
helpText="Keyword difficulty score."
|
||||||
|
field="keywordDifficulty"
|
||||||
|
current={sortField}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={toggleSort}
|
||||||
|
className="justify-end"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => <ScoreCell value={getValue()} />,
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[selectAnchorRef, sortDir, sortField, toggleSort],
|
||||||
|
);
|
||||||
|
const table = useAppTable({
|
||||||
|
data: filteredRows,
|
||||||
|
columns,
|
||||||
|
state: { rowSelection },
|
||||||
|
onRowSelectionChange: (updater) => {
|
||||||
|
const next =
|
||||||
|
typeof updater === "function" ? updater(rowSelection) : updater;
|
||||||
|
setSelectedRows(
|
||||||
|
new Set(
|
||||||
|
Object.entries(next)
|
||||||
|
.filter(([, selected]) => selected)
|
||||||
|
.map(([keyword]) => keyword),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getRowId: (row) => row.keyword,
|
||||||
|
enableRowSelection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{filteredRows.length === 0 ? (
|
||||||
|
<EmptyFilterResults
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
resetFilters={resetFilters}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AppDataTable
|
||||||
|
table={table}
|
||||||
|
className="table table-xs w-full"
|
||||||
|
wrapperClassName="h-full overflow-y-auto"
|
||||||
|
getRowProps={(row) => ({
|
||||||
|
className: `cursor-pointer border-b border-base-200 hover:bg-base-200/50 ${
|
||||||
|
overviewKeyword?.keyword === row.original.keyword
|
||||||
|
? "bg-primary/5 border-l-2 border-l-primary"
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
onClick: () => handleRowClick(row.original),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreCell({ value }: { value: number | null }) {
|
||||||
|
if (value == null) return null;
|
||||||
|
let tierClass = "bg-success/20 text-success";
|
||||||
|
if (value > 60) tierClass = "bg-error/20 text-error";
|
||||||
|
else if (value > 30) tierClass = "bg-warning/20 text-warning";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold ${tierClass}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -139,6 +139,7 @@ export function useKeywordResearchController(
|
|||||||
serpQuery: state.serpQuery,
|
serpQuery: state.serpQuery,
|
||||||
serpResults: state.serpResults,
|
serpResults: state.serpResults,
|
||||||
setMobileTab: state.setMobileTab,
|
setMobileTab: state.setMobileTab,
|
||||||
|
setSelectedRows: state.setSelectedRows,
|
||||||
setSerpPage: state.setSerpPage,
|
setSerpPage: state.setSerpPage,
|
||||||
setShowFilters: state.setShowFilters,
|
setShowFilters: state.setShowFilters,
|
||||||
setShowSaveDialog: state.setShowSaveDialog,
|
setShowSaveDialog: state.setShowSaveDialog,
|
||||||
@ -165,8 +166,13 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
|||||||
const uiState = useKeywordUiState(
|
const uiState = useKeywordUiState(
|
||||||
Object.values(filterValues).some((v) => v.trim() !== ""),
|
Object.values(filterValues).some((v) => v.trim() !== ""),
|
||||||
);
|
);
|
||||||
const { selectedRows, clearSelection, toggleRowSelection, toggleAllRows } =
|
const {
|
||||||
useKeywordSelection();
|
selectedRows,
|
||||||
|
setSelectedRows,
|
||||||
|
clearSelection,
|
||||||
|
toggleRowSelection,
|
||||||
|
toggleAllRows,
|
||||||
|
} = useKeywordSelection();
|
||||||
const {
|
const {
|
||||||
setSerpKeyword,
|
setSerpKeyword,
|
||||||
serpPage,
|
serpPage,
|
||||||
@ -329,6 +335,7 @@ function useKeywordControllerState(input: KeywordResearchControllerInput) {
|
|||||||
searchedKeyword,
|
searchedKeyword,
|
||||||
selectedKeyword: uiState.selectedKeyword,
|
selectedKeyword: uiState.selectedKeyword,
|
||||||
selectedRows,
|
selectedRows,
|
||||||
|
setSelectedRows,
|
||||||
saveMutation,
|
saveMutation,
|
||||||
setPreferredLocationCode,
|
setPreferredLocationCode,
|
||||||
setSelectedKeyword: uiState.setSelectedKeyword,
|
setSelectedKeyword: uiState.setSelectedKeyword,
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
flexRender,
|
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
@ -14,11 +10,16 @@ import { toast } from "sonner";
|
|||||||
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
|
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
|
||||||
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
makeSelectionColumn,
|
||||||
|
useAppTable,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
import { SortableHeader } from "./RankTrackingColumns";
|
import { SortableHeader } from "./RankTrackingColumns";
|
||||||
import {
|
import {
|
||||||
applyShiftRangeSelection,
|
applyShiftRangeSelection,
|
||||||
type SelectionAnchor,
|
type SelectionAnchor,
|
||||||
} from "./tableSelection";
|
} from "@/client/components/table/tableSelection";
|
||||||
|
|
||||||
type SuggestedKeyword = {
|
type SuggestedKeyword = {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
@ -151,31 +152,7 @@ export function KeywordSuggestionStep({
|
|||||||
|
|
||||||
const columns = useMemo<ColumnDef<SuggestedKeyword>[]>(
|
const columns = useMemo<ColumnDef<SuggestedKeyword>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
makeSelectionColumn<SuggestedKeyword>(selectAnchorRef),
|
||||||
id: "select",
|
|
||||||
size: 32,
|
|
||||||
enableSorting: false,
|
|
||||||
header: ({ table }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-xs"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row, table }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-xs"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
applyShiftRangeSelection(event, row, table, selectAnchorRef);
|
|
||||||
}}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...baseColumns,
|
...baseColumns,
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@ -217,14 +194,13 @@ export function KeywordSuggestionStep({
|
|||||||
}
|
}
|
||||||
}, [suggestionsQuery.data, hasInitialized]);
|
}, [suggestionsQuery.data, hasInitialized]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useAppTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
state: { rowSelection, sorting },
|
state: { rowSelection, sorting },
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
withSorting: true,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -336,49 +312,22 @@ export function KeywordSuggestionStep({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto max-h-[400px] border border-base-300 rounded-lg">
|
<AppDataTable
|
||||||
<table className="table table-xs table-pin-rows w-full">
|
table={table}
|
||||||
<thead>
|
className="table table-xs table-pin-rows w-full"
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
wrapperClassName="overflow-y-auto max-h-[400px] border border-base-300 rounded-lg"
|
||||||
<tr key={headerGroup.id}>
|
stickyHeader
|
||||||
{headerGroup.headers.map((header) => (
|
getRowProps={(row) => ({
|
||||||
<th key={header.id} className="bg-base-200">
|
className: "hover:bg-base-200/50 cursor-pointer",
|
||||||
{header.isPlaceholder
|
onClick: (event) => {
|
||||||
? null
|
if (applyShiftRangeSelection(event, row, table, selectAnchorRef)) {
|
||||||
: flexRender(
|
return;
|
||||||
header.column.columnDef.header,
|
}
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr
|
|
||||||
key={row.id}
|
|
||||||
className="hover:bg-base-200/50 cursor-pointer"
|
|
||||||
onClick={(event) => {
|
|
||||||
if (
|
|
||||||
applyShiftRangeSelection(event, row, table, selectAnchorRef)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
row.toggleSelected();
|
row.toggleSelected();
|
||||||
}}
|
},
|
||||||
>
|
})}
|
||||||
{row.getVisibleCells().map((cell) => (
|
/>
|
||||||
<td key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-1">
|
<div className="flex items-center justify-end gap-3 pt-1">
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useMemo, type MutableRefObject } from "react";
|
import { useMemo, type MutableRefObject } from "react";
|
||||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
||||||
|
import { makeSelectionColumn } from "@/client/components/table/AppDataTable";
|
||||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||||
import {
|
import {
|
||||||
comparePositions,
|
comparePositions,
|
||||||
@ -11,10 +12,7 @@ import {
|
|||||||
SerpFeatureTags,
|
SerpFeatureTags,
|
||||||
VolumeCell,
|
VolumeCell,
|
||||||
} from "./RankTrackingTableParts";
|
} from "./RankTrackingTableParts";
|
||||||
import {
|
import type { SelectionAnchor } from "@/client/components/table/tableSelection";
|
||||||
applyShiftRangeSelection,
|
|
||||||
type SelectionAnchor,
|
|
||||||
} from "./tableSelection";
|
|
||||||
|
|
||||||
const HEADER_TOOLTIPS: Record<string, string> = {
|
const HEADER_TOOLTIPS: Record<string, string> = {
|
||||||
keyword: "The search term being tracked in Google",
|
keyword: "The search term being tracked in Google",
|
||||||
@ -114,30 +112,7 @@ const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
|
|||||||
function makeSelectColumn(
|
function makeSelectColumn(
|
||||||
anchorRef: MutableRefObject<SelectionAnchor | null>,
|
anchorRef: MutableRefObject<SelectionAnchor | null>,
|
||||||
): ColumnDef<RankTrackingRow> {
|
): ColumnDef<RankTrackingRow> {
|
||||||
return {
|
return makeSelectionColumn<RankTrackingRow>(anchorRef);
|
||||||
id: "select",
|
|
||||||
size: 32,
|
|
||||||
enableSorting: false,
|
|
||||||
header: ({ table }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-xs"
|
|
||||||
checked={table.getIsAllRowsSelected()}
|
|
||||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row, table }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-xs"
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onClick={(event) =>
|
|
||||||
applyShiftRangeSelection(event, row, table, anchorRef)
|
|
||||||
}
|
|
||||||
onChange={row.getToggleSelectedHandler()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keywordColumn: ColumnDef<RankTrackingRow> = {
|
const keywordColumn: ColumnDef<RankTrackingRow> = {
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, Trash2 } from "lucide-react";
|
import { Loader2, Trash2 } from "lucide-react";
|
||||||
import {
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import { Modal } from "@/client/components/Modal";
|
import { Modal } from "@/client/components/Modal";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
useAppTable,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||||
import { useRankTrackingColumns } from "./RankTrackingColumns";
|
import { useRankTrackingColumns } from "./RankTrackingColumns";
|
||||||
import type { SelectionAnchor } from "./tableSelection";
|
import type { SelectionAnchor } from "@/client/components/table/tableSelection";
|
||||||
|
|
||||||
export function RankTrackingTable({
|
export function RankTrackingTable({
|
||||||
totalCount,
|
totalCount,
|
||||||
@ -47,14 +45,13 @@ export function RankTrackingTable({
|
|||||||
selectAnchorRef,
|
selectAnchorRef,
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useAppTable({
|
||||||
data: rows,
|
data: rows,
|
||||||
columns,
|
columns,
|
||||||
initialState: {
|
initialState: {
|
||||||
sorting: [{ id: defaultSortId, desc: false }],
|
sorting: [{ id: defaultSortId, desc: false }],
|
||||||
},
|
},
|
||||||
getCoreRowModel: getCoreRowModel(),
|
withSorting: true,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getRowId: (row) => row.trackingKeywordId,
|
getRowId: (row) => row.trackingKeywordId,
|
||||||
enableRowSelection: true,
|
enableRowSelection: true,
|
||||||
});
|
});
|
||||||
@ -165,37 +162,7 @@ export function RankTrackingTable({
|
|||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<AppDataTable table={table} getCellClassName={() => "align-top"} />
|
||||||
<table className="table table-sm">
|
|
||||||
<thead>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<tr key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<th key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{table.getRowModel().rows.map((row) => (
|
|
||||||
<tr key={row.id}>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td key={cell.id} className="align-top">
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-base-content/60 pt-2">
|
<p className="text-xs text-base-content/60 pt-2">
|
||||||
{rows.length} of {totalCount} keywords
|
{rows.length} of {totalCount} keywords
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { RowSelectionState, Updater } from "@tanstack/react-table";
|
|||||||
import {
|
import {
|
||||||
applyShiftRangeSelection,
|
applyShiftRangeSelection,
|
||||||
type SelectionAnchor,
|
type SelectionAnchor,
|
||||||
} from "./tableSelection";
|
} from "@/client/components/table/tableSelection";
|
||||||
|
|
||||||
function makeRow(id: string, selectedIds: Set<string>) {
|
function makeRow(id: string, selectedIds: Set<string>) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user