refactor: convert rank tracking table to Tanstack Table (#111)
This commit is contained in:
parent
0024691da8
commit
1b6bf8fd94
@ -9,8 +9,9 @@
|
|||||||
"src/routes/**/*.tsx",
|
"src/routes/**/*.tsx",
|
||||||
// Drizzle config (plugin disabled due to cloudflare:workers import issues)
|
// Drizzle config (plugin disabled due to cloudflare:workers import issues)
|
||||||
"drizzle.config.ts",
|
"drizzle.config.ts",
|
||||||
// DB index re-exports schema for convenience
|
// DB schema — exports consumed via `import * as schema` / drizzle()
|
||||||
"src/db/index.ts",
|
"src/db/index.ts",
|
||||||
|
"src/db/better-auth-schema.ts",
|
||||||
],
|
],
|
||||||
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
||||||
"ignore": ["drizzle-prod.config.ts"],
|
"ignore": ["drizzle-prod.config.ts"],
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ci": "vitest run --reporter=dot",
|
"test:ci": "vitest run --reporter=dot",
|
||||||
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
"billing:backlinks": "tsx scripts/backlinks-cost-profile.ts",
|
||||||
|
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
|
||||||
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
"ci:check": "prettier --check . && knip && tsc --noEmit && oxlint . --type-aware"
|
||||||
},
|
},
|
||||||
"cloudflare": {
|
"cloudflare": {
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import { createBacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
import { createBacklinksService } from "@/server/features/backlinks/services/BacklinksService";
|
||||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
@ -6,6 +5,7 @@ import type {
|
|||||||
BacklinksLookupInput,
|
BacklinksLookupInput,
|
||||||
BacklinksTargetScope,
|
BacklinksTargetScope,
|
||||||
} from "@/types/schemas/backlinks";
|
} from "@/types/schemas/backlinks";
|
||||||
|
import { loadLocalEnv, parseArgs } from "./cli-utils";
|
||||||
|
|
||||||
loadLocalEnv();
|
loadLocalEnv();
|
||||||
|
|
||||||
@ -111,35 +111,6 @@ function buildBillingCustomer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv: string[]) {
|
|
||||||
const parsed: Record<string, string> = {};
|
|
||||||
|
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
|
||||||
const token = argv[index];
|
|
||||||
if (!token.startsWith("--")) continue;
|
|
||||||
|
|
||||||
const withoutPrefix = token.slice(2);
|
|
||||||
const separatorIndex = withoutPrefix.indexOf("=");
|
|
||||||
if (separatorIndex >= 0) {
|
|
||||||
parsed[withoutPrefix.slice(0, separatorIndex)] = withoutPrefix.slice(
|
|
||||||
separatorIndex + 1,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = argv[index + 1];
|
|
||||||
if (!next || next.startsWith("--")) {
|
|
||||||
parsed[withoutPrefix] = "true";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed[withoutPrefix] = next;
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBoolean(value: string | undefined, fallback: boolean) {
|
function parseBoolean(value: string | undefined, fallback: boolean) {
|
||||||
if (value == null) return fallback;
|
if (value == null) return fallback;
|
||||||
return value === "true";
|
return value === "true";
|
||||||
@ -159,26 +130,6 @@ function parsePositiveInteger(value: string | undefined, fallback: number) {
|
|||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLocalEnv() {
|
|
||||||
for (const path of [".env.local", ".env"]) {
|
|
||||||
if (!existsSync(path)) continue;
|
|
||||||
const content = readFileSync(path, "utf8");
|
|
||||||
for (const line of content.split(/\r?\n/u)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
||||||
|
|
||||||
const separatorIndex = trimmed.indexOf("=");
|
|
||||||
if (separatorIndex < 0) continue;
|
|
||||||
|
|
||||||
const key = trimmed.slice(0, separatorIndex).trim();
|
|
||||||
const rawValue = trimmed.slice(separatorIndex + 1).trim();
|
|
||||||
if (!key || process.env[key]) continue;
|
|
||||||
|
|
||||||
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function printUsageAndExit(message: string): never {
|
function printUsageAndExit(message: string): never {
|
||||||
console.error(message);
|
console.error(message);
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
41
scripts/cli-utils.ts
Normal file
41
scripts/cli-utils.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export function parseArgs(argv: string[]) {
|
||||||
|
const parsed: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (!token.startsWith("--")) continue;
|
||||||
|
const withoutPrefix = token.slice(2);
|
||||||
|
const sep = withoutPrefix.indexOf("=");
|
||||||
|
if (sep >= 0) {
|
||||||
|
parsed[withoutPrefix.slice(0, sep)] = withoutPrefix.slice(sep + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next || next.startsWith("--")) {
|
||||||
|
parsed[withoutPrefix] = "true";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parsed[withoutPrefix] = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLocalEnv() {
|
||||||
|
for (const path of [".env.local", ".env"]) {
|
||||||
|
if (!existsSync(path)) continue;
|
||||||
|
const content = readFileSync(path, "utf8");
|
||||||
|
for (const line of content.split(/\r?\n/u)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const sep = trimmed.indexOf("=");
|
||||||
|
if (sep < 0) continue;
|
||||||
|
const key = trimmed.slice(0, sep).trim();
|
||||||
|
const rawValue = trimmed.slice(sep + 1).trim();
|
||||||
|
if (!key || process.env[key]) continue;
|
||||||
|
process.env[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
273
scripts/seed-rank-tracking.ts
Normal file
273
scripts/seed-rank-tracking.ts
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Seed the local D1 database with rank tracking data from DataForSEO.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm seed:rank-tracking --domain=example.com [--projectId=xxx]
|
||||||
|
*
|
||||||
|
* Requires:
|
||||||
|
* - DATAFORSEO_API_KEY in .env.local or .env
|
||||||
|
* - Local D1 database (run `pnpm db:migrate:local` first)
|
||||||
|
* - At least one project in the database (start the dev server once)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import process from "node:process";
|
||||||
|
import { getPlatformProxy } from "wrangler";
|
||||||
|
import { drizzle } from "drizzle-orm/d1";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
DataforseoLabsApi,
|
||||||
|
DataforseoLabsGoogleRankedKeywordsLiveRequestInfo,
|
||||||
|
} from "dataforseo-client";
|
||||||
|
import * as schema from "../src/db/schema";
|
||||||
|
import {
|
||||||
|
domainRankedKeywordItemSchema,
|
||||||
|
type DomainRankedKeywordItem,
|
||||||
|
} from "../src/server/lib/dataforseoSchemas";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { loadLocalEnv, parseArgs } from "./cli-utils";
|
||||||
|
|
||||||
|
loadLocalEnv();
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
await main();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const domain = normalizeDomain(args.domain);
|
||||||
|
if (!domain) {
|
||||||
|
exitWithUsage("Missing --domain argument.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.DATAFORSEO_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
exitWithUsage("Missing DATAFORSEO_API_KEY. Set it in .env.local or .env.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Setting up local D1 connection...`);
|
||||||
|
const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>();
|
||||||
|
const db = drizzle(env.DB, { schema });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve project
|
||||||
|
const projectId = args.projectId ?? (await findFirstProject(db));
|
||||||
|
if (!projectId) {
|
||||||
|
exitWithUsage(
|
||||||
|
"No projects found in local DB. Start the dev server and create a project first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingProject = await db.query.projects.findFirst({
|
||||||
|
where: eq(schema.projects.id, projectId),
|
||||||
|
});
|
||||||
|
if (!existingProject) {
|
||||||
|
exitWithUsage(`Project ${projectId} not found in local DB.`);
|
||||||
|
}
|
||||||
|
console.log(`Using project: ${existingProject.name} (${projectId})`);
|
||||||
|
|
||||||
|
// Check for existing config
|
||||||
|
const existingConfig = await db.query.rankTrackingConfigs.findFirst({
|
||||||
|
where: eq(schema.rankTrackingConfigs.domain, domain),
|
||||||
|
});
|
||||||
|
if (existingConfig) {
|
||||||
|
console.log(
|
||||||
|
`Rank tracking config already exists for ${domain} — skipping. Delete it manually to re-seed.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch top 50 keywords from DataForSEO
|
||||||
|
console.log(`Fetching top 50 keywords for ${domain} from DataForSEO...`);
|
||||||
|
const rankedItems = await fetchRankedKeywords(apiKey, domain, 50);
|
||||||
|
console.log(`Got ${rankedItems.length} ranked keywords.`);
|
||||||
|
|
||||||
|
if (rankedItems.length === 0) {
|
||||||
|
console.log("No keywords found for this domain. Nothing to seed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to keyword/position/url
|
||||||
|
const keywords = rankedItems
|
||||||
|
.map(mapKeywordItem)
|
||||||
|
.filter((k): k is NonNullable<typeof k> => k !== null);
|
||||||
|
console.log(`Mapped ${keywords.length} valid keywords.`);
|
||||||
|
|
||||||
|
// Generate IDs
|
||||||
|
const configId = crypto.randomUUID();
|
||||||
|
const runId = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Insert config
|
||||||
|
await db.insert(schema.rankTrackingConfigs).values({
|
||||||
|
id: configId,
|
||||||
|
projectId,
|
||||||
|
domain,
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
devices: "mobile",
|
||||||
|
scheduleInterval: "weekly",
|
||||||
|
isActive: true,
|
||||||
|
lastCheckedAt: now,
|
||||||
|
});
|
||||||
|
console.log(`Created config for ${domain} (mobile, weekly).`);
|
||||||
|
|
||||||
|
// Insert keywords (batch individual statements to respect D1 bind limits)
|
||||||
|
const keywordRows = keywords.map((k) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
configId,
|
||||||
|
keyword: k.keyword,
|
||||||
|
}));
|
||||||
|
const keywordStmts = keywordRows.map((row) =>
|
||||||
|
db.insert(schema.rankTrackingKeywords).values(row).onConflictDoNothing(),
|
||||||
|
);
|
||||||
|
if (keywordStmts.length > 0) {
|
||||||
|
const [first, ...rest] = keywordStmts;
|
||||||
|
await db.batch([first, ...rest]);
|
||||||
|
}
|
||||||
|
console.log(`Inserted ${keywordRows.length} keywords.`);
|
||||||
|
|
||||||
|
// Insert completed run
|
||||||
|
await db.insert(schema.rankCheckRuns).values({
|
||||||
|
id: runId,
|
||||||
|
configId,
|
||||||
|
projectId,
|
||||||
|
status: "completed",
|
||||||
|
keywordsTotal: keywordRows.length,
|
||||||
|
keywordsChecked: keywordRows.length,
|
||||||
|
completedAt: now,
|
||||||
|
});
|
||||||
|
console.log(`Created completed run.`);
|
||||||
|
|
||||||
|
// Insert snapshots (mobile device)
|
||||||
|
const snapshotStmts = keywordRows.map((kw, i) =>
|
||||||
|
db
|
||||||
|
.insert(schema.rankSnapshots)
|
||||||
|
.values({
|
||||||
|
runId,
|
||||||
|
trackingKeywordId: kw.id,
|
||||||
|
keyword: kw.keyword,
|
||||||
|
device: "mobile" as const,
|
||||||
|
position: keywords[i].position,
|
||||||
|
url: keywords[i].url,
|
||||||
|
serpFeatures: null,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing(),
|
||||||
|
);
|
||||||
|
if (snapshotStmts.length > 0) {
|
||||||
|
const [first, ...rest] = snapshotStmts;
|
||||||
|
await db.batch([first, ...rest]);
|
||||||
|
}
|
||||||
|
console.log(`Inserted ${keywordRows.length} snapshots.`);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nDone! Seeded rank tracking for ${domain} with ${keywords.length} keywords.`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DataForSEO
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function fetchRankedKeywords(
|
||||||
|
apiKey: string,
|
||||||
|
domain: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<DomainRankedKeywordItem[]> {
|
||||||
|
const api = new DataforseoLabsApi("https://api.dataforseo.com", {
|
||||||
|
fetch: (url: RequestInfo, init?: RequestInit) => {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
headers.set("Authorization", `Basic ${apiKey}`);
|
||||||
|
return fetch(url, { ...init, headers });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = new DataforseoLabsGoogleRankedKeywordsLiveRequestInfo({
|
||||||
|
target: domain,
|
||||||
|
location_code: 2840,
|
||||||
|
language_code: "en",
|
||||||
|
limit,
|
||||||
|
order_by: ["keyword_data.keyword_info.search_volume,desc"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await api.googleRankedKeywordsLive([req]);
|
||||||
|
|
||||||
|
if (!response || response.status_code !== 20000) {
|
||||||
|
throw new Error(
|
||||||
|
`DataForSEO error: ${response?.status_message ?? "unknown"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = response.tasks?.[0];
|
||||||
|
if (!task || task.status_code !== 20000) {
|
||||||
|
throw new Error(
|
||||||
|
`DataForSEO task error: ${task?.status_message ?? "no task returned"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawItems = task.result?.[0]?.items ?? [];
|
||||||
|
const parsed = z.array(domainRankedKeywordItemSchema).safeParse(rawItems);
|
||||||
|
if (!parsed.success) {
|
||||||
|
console.error("Schema validation issues:", parsed.error.issues.slice(0, 3));
|
||||||
|
throw new Error("DataForSEO response failed schema validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mapping (mirrors DomainService.mapKeywordItem)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function mapKeywordItem(item: DomainRankedKeywordItem) {
|
||||||
|
const keywordData = item.keyword_data;
|
||||||
|
const rankedSerpElement = item.ranked_serp_element;
|
||||||
|
const serpItem = rankedSerpElement?.serp_item;
|
||||||
|
|
||||||
|
const keyword = keywordData?.keyword ?? item.keyword;
|
||||||
|
if (!keyword) return null;
|
||||||
|
|
||||||
|
const position =
|
||||||
|
serpItem?.rank_absolute ?? rankedSerpElement?.rank_absolute ?? null;
|
||||||
|
const url = serpItem?.url ?? rankedSerpElement?.url ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyword: keyword.toLowerCase().trim(),
|
||||||
|
position: position != null ? Math.round(position) : null,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type SeedDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|
||||||
|
async function findFirstProject(db: SeedDb): Promise<string | null> {
|
||||||
|
const row = await db.query.projects.findFirst();
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function normalizeDomain(raw: string | undefined): string | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
return raw
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^https?:\/\//u, "")
|
||||||
|
.replace(/\/.*$/u, "")
|
||||||
|
.replace(/^www\./u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitWithUsage(message: string): never {
|
||||||
|
console.error(message);
|
||||||
|
console.error(
|
||||||
|
"Usage: pnpm seed:rank-tracking --domain=example.com [--projectId=xxx]",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@ -3,10 +3,9 @@ import {
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getExpandedRowModel,
|
getExpandedRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type SortingState,
|
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
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";
|
||||||
@ -17,17 +16,14 @@ export function BacklinksTable({
|
|||||||
}: {
|
}: {
|
||||||
rows: BacklinksOverviewData["backlinks"];
|
rows: BacklinksOverviewData["backlinks"];
|
||||||
}) {
|
}) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
|
||||||
{ id: "firstSeen", desc: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
|
const groupedData = useMemo(() => groupBacklinksByDomain(rows), [rows]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: groupedData,
|
data: groupedData,
|
||||||
columns: backlinksColumns,
|
columns: backlinksColumns,
|
||||||
state: { sorting },
|
initialState: {
|
||||||
onSortingChange: setSorting,
|
sorting: [{ id: "firstSeen", desc: true }],
|
||||||
|
},
|
||||||
getSubRows: (row) => row.subRows,
|
getSubRows: (row) => row.subRows,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
|||||||
119
src/client/features/rank-tracking/RankTrackingColumns.tsx
Normal file
119
src/client/features/rank-tracking/RankTrackingColumns.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||||
|
import type { ColumnDef, SortingFn } from "@tanstack/react-table";
|
||||||
|
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||||
|
import { comparePositions, DeviceRankCell } from "./RankTrackingTableParts";
|
||||||
|
|
||||||
|
const HEADER_TOOLTIPS: Record<string, string> = {
|
||||||
|
keyword: "The search term being tracked",
|
||||||
|
desktopPosition: "Google ranking details on desktop devices",
|
||||||
|
mobilePosition: "Google ranking details on mobile devices",
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortableHeader({
|
||||||
|
column,
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
getToggleSortingHandler: () => ((event: unknown) => void) | undefined;
|
||||||
|
};
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
}) {
|
||||||
|
const sorted = column.getIsSorted();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 text-xs uppercase tracking-wide font-medium text-base-content/60 transition-colors hover:text-base-content"
|
||||||
|
onClick={column.getToggleSortingHandler()}
|
||||||
|
title={HEADER_TOOLTIPS[id]}
|
||||||
|
aria-label={`Sort by ${label}`}
|
||||||
|
aria-pressed={!!sorted}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{sorted === "asc" ? (
|
||||||
|
<ArrowUp className="size-3 shrink-0" />
|
||||||
|
) : sorted === "desc" ? (
|
||||||
|
<ArrowDown className="size-3 shrink-0" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionSort: SortingFn<RankTrackingRow> = (rowA, rowB, columnId) => {
|
||||||
|
const device = columnId === "desktopPosition" ? "desktop" : "mobile";
|
||||||
|
return comparePositions(
|
||||||
|
rowA.original[device].position,
|
||||||
|
rowB.original[device].position,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectColumn: ColumnDef<RankTrackingRow> = {
|
||||||
|
id: "select",
|
||||||
|
size: 32,
|
||||||
|
enableSorting: false,
|
||||||
|
header: ({ table }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={table.getIsAllRowsSelected()}
|
||||||
|
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-xs"
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={row.getToggleSelectedHandler()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const keywordColumn: ColumnDef<RankTrackingRow> = {
|
||||||
|
id: "keyword",
|
||||||
|
accessorKey: "keyword",
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} label="Keyword" id="keyword" />
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-medium">{getValue<string>()}</span>
|
||||||
|
),
|
||||||
|
sortingFn: "alphanumeric",
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeDeviceColumn(
|
||||||
|
device: "desktop" | "mobile",
|
||||||
|
domain: string,
|
||||||
|
): ColumnDef<RankTrackingRow> {
|
||||||
|
const id = device === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||||
|
const label = device === "desktop" ? "Desktop" : "Mobile";
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
accessorFn: (row) => row[device].position,
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} label={label} id={id} />
|
||||||
|
),
|
||||||
|
size: 176,
|
||||||
|
minSize: 176,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DeviceRankCell result={row.original[device]} domain={domain} />
|
||||||
|
),
|
||||||
|
sortingFn: positionSort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRankTrackingColumns(
|
||||||
|
showDesktop: boolean,
|
||||||
|
showMobile: boolean,
|
||||||
|
domain: string,
|
||||||
|
): ColumnDef<RankTrackingRow>[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
const cols: ColumnDef<RankTrackingRow>[] = [selectColumn, keywordColumn];
|
||||||
|
if (showDesktop) cols.push(makeDeviceColumn("desktop", domain));
|
||||||
|
if (showMobile) cols.push(makeDeviceColumn("mobile", domain));
|
||||||
|
return cols;
|
||||||
|
}, [showDesktop, showMobile, domain]);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@ -14,9 +14,12 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { RankTrackingTable, exportRankTrackingCsv } from "./RankTrackingTable";
|
import { RankTrackingTable } from "./RankTrackingTable";
|
||||||
import type { RankTrackingConfig } from "@/types/schemas/rank-tracking";
|
import { exportRankTrackingCsv } from "./RankTrackingTableParts";
|
||||||
import type { ComparePeriod } from "@/types/schemas/rank-tracking";
|
import type {
|
||||||
|
RankTrackingConfig,
|
||||||
|
ComparePeriod,
|
||||||
|
} from "@/types/schemas/rank-tracking";
|
||||||
import { LOCATIONS } from "@/client/features/keywords/locations";
|
import { LOCATIONS } from "@/client/features/keywords/locations";
|
||||||
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
|
import { devicesLabel, scheduleLabel } from "@/shared/rank-tracking";
|
||||||
import { ActionsMenu } from "./ActionsMenu";
|
import { ActionsMenu } from "./ActionsMenu";
|
||||||
@ -31,7 +34,6 @@ import {
|
|||||||
import { CheckConfirmModal } from "./CheckConfirmModal";
|
import { CheckConfirmModal } from "./CheckConfirmModal";
|
||||||
import { useRankCheckTrigger } from "./useRankCheckTrigger";
|
import { useRankCheckTrigger } from "./useRankCheckTrigger";
|
||||||
import { useRankRunPolling } from "./useRankRunPolling";
|
import { useRankRunPolling } from "./useRankRunPolling";
|
||||||
import { useRankTableSort } from "./useRankTableSort";
|
|
||||||
|
|
||||||
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
|
const COMPARE_PERIODS: ReadonlySet<string> = new Set([
|
||||||
"previous",
|
"previous",
|
||||||
@ -120,18 +122,17 @@ export function RankTrackingDomainDetail({
|
|||||||
setPendingCheck({ count, keywordIds });
|
setPendingCheck({ count, keywordIds });
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows = resultsData?.rows ?? [];
|
const rows = resultsData?.rows;
|
||||||
const run = resultsData?.run;
|
const run = resultsData?.run;
|
||||||
const showDesktop = config.devices !== "mobile";
|
const showDesktop = config.devices !== "mobile";
|
||||||
const showMobile = config.devices !== "desktop";
|
const showMobile = config.devices !== "desktop";
|
||||||
const filtered = applyFilters(rows, filters);
|
const filtered = useMemo(
|
||||||
const activeFilterCount = countActiveFilters(filters);
|
() => applyFilters(rows ?? [], filters),
|
||||||
const defaultSort =
|
[rows, filters],
|
||||||
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
|
|
||||||
const { sorted, sortField, sortDir, handleSort } = useRankTableSort(
|
|
||||||
filtered,
|
|
||||||
defaultSort,
|
|
||||||
);
|
);
|
||||||
|
const activeFilterCount = countActiveFilters(filters);
|
||||||
|
const defaultSortId =
|
||||||
|
config.devices === "desktop" ? "desktopPosition" : "mobilePosition";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -263,24 +264,24 @@ export function RankTrackingDomainDetail({
|
|||||||
|
|
||||||
<ActionsMenu
|
<ActionsMenu
|
||||||
onCheckNow={() => {
|
onCheckNow={() => {
|
||||||
const count = costEstimate?.keywordCount ?? rows.length;
|
const count = costEstimate?.keywordCount ?? rows?.length ?? 0;
|
||||||
if (count > 0) requestCheck(count);
|
if (count > 0) requestCheck(count);
|
||||||
}}
|
}}
|
||||||
onExport={() =>
|
onExport={() =>
|
||||||
exportRankTrackingCsv(
|
exportRankTrackingCsv(
|
||||||
sorted,
|
filtered,
|
||||||
showDesktop,
|
showDesktop,
|
||||||
showMobile,
|
showMobile,
|
||||||
config.domain,
|
config.domain,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onCopyKeywords={() => {
|
onCopyKeywords={() => {
|
||||||
const text = sorted.map((r) => r.keyword).join("\n");
|
const text = filtered.map((r) => r.keyword).join("\n");
|
||||||
void navigator.clipboard.writeText(text);
|
void navigator.clipboard.writeText(text);
|
||||||
toast.success("Keywords copied to clipboard");
|
toast.success("Keywords copied to clipboard");
|
||||||
}}
|
}}
|
||||||
isRunning={isBusy}
|
isRunning={isBusy}
|
||||||
hasData={sorted.length > 0}
|
hasData={filtered.length > 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -297,14 +298,12 @@ export function RankTrackingDomainDetail({
|
|||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<RankTrackingTable
|
<RankTrackingTable
|
||||||
totalCount={rows.length}
|
totalCount={rows?.length ?? 0}
|
||||||
sorted={sorted}
|
rows={filtered}
|
||||||
resultsLoading={resultsLoading}
|
resultsLoading={resultsLoading}
|
||||||
showDesktop={showDesktop}
|
showDesktop={showDesktop}
|
||||||
showMobile={showMobile}
|
showMobile={showMobile}
|
||||||
sortField={sortField}
|
defaultSortId={defaultSortId}
|
||||||
sortDir={sortDir}
|
|
||||||
onSort={handleSort}
|
|
||||||
domain={config.domain}
|
domain={config.domain}
|
||||||
configId={config.id}
|
configId={config.id}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
|||||||
@ -139,26 +139,28 @@ export function applyFilters(
|
|||||||
rows: RankTrackingRow[],
|
rows: RankTrackingRow[],
|
||||||
filters: Filters,
|
filters: Filters,
|
||||||
): RankTrackingRow[] {
|
): RankTrackingRow[] {
|
||||||
|
const includeTerms = filters.include
|
||||||
|
? filters.include
|
||||||
|
.toLowerCase()
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const excludeTerms = filters.exclude
|
||||||
|
? filters.exclude
|
||||||
|
.toLowerCase()
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
return rows.filter((row) => {
|
return rows.filter((row) => {
|
||||||
const kw = row.keyword.toLowerCase();
|
const kw = row.keyword.toLowerCase();
|
||||||
|
|
||||||
if (filters.include) {
|
if (includeTerms.length > 0 && !includeTerms.some((t) => kw.includes(t)))
|
||||||
const terms = filters.include
|
return false;
|
||||||
.toLowerCase()
|
|
||||||
.split(",")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (terms.length > 0 && !terms.some((t) => kw.includes(t))) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.exclude) {
|
if (excludeTerms.some((t) => kw.includes(t))) return false;
|
||||||
const terms = filters.exclude
|
|
||||||
.toLowerCase()
|
|
||||||
.split(",")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (terms.some((t) => kw.includes(t))) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.minDesktopPos || filters.maxDesktopPos) {
|
if (filters.minDesktopPos || filters.maxDesktopPos) {
|
||||||
const min = filters.minDesktopPos ? Number(filters.minDesktopPos) : 0;
|
const min = filters.minDesktopPos ? Number(filters.minDesktopPos) : 0;
|
||||||
|
|||||||
@ -1,64 +1,66 @@
|
|||||||
import { useState } from "react";
|
import { 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 { 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 {
|
import { useRankTrackingColumns } from "./RankTrackingColumns";
|
||||||
SortableHeader,
|
|
||||||
DeviceRankCell,
|
|
||||||
type SortField,
|
|
||||||
type SortDir,
|
|
||||||
} from "./RankTrackingTableParts";
|
|
||||||
export {
|
|
||||||
comparePositions,
|
|
||||||
exportRankTrackingCsv,
|
|
||||||
} from "./RankTrackingTableParts";
|
|
||||||
export type { SortField, SortDir } from "./RankTrackingTableParts";
|
|
||||||
|
|
||||||
export function RankTrackingTable({
|
export function RankTrackingTable({
|
||||||
totalCount,
|
totalCount,
|
||||||
sorted,
|
rows,
|
||||||
resultsLoading,
|
resultsLoading,
|
||||||
showDesktop,
|
showDesktop,
|
||||||
showMobile,
|
showMobile,
|
||||||
sortField,
|
defaultSortId,
|
||||||
sortDir,
|
|
||||||
onSort,
|
|
||||||
domain,
|
domain,
|
||||||
configId,
|
configId,
|
||||||
projectId,
|
projectId,
|
||||||
}: {
|
}: {
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
sorted: RankTrackingRow[];
|
rows: RankTrackingRow[];
|
||||||
resultsLoading: boolean;
|
resultsLoading: boolean;
|
||||||
showDesktop: boolean;
|
showDesktop: boolean;
|
||||||
showMobile: boolean;
|
showMobile: boolean;
|
||||||
sortField: SortField;
|
defaultSortId: string;
|
||||||
sortDir: SortDir;
|
|
||||||
onSort: (field: SortField) => void;
|
|
||||||
domain: string;
|
domain: string;
|
||||||
configId: string;
|
configId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
// Only count/act on selections that are currently visible
|
const columns = useRankTrackingColumns(showDesktop, showMobile, domain);
|
||||||
const visibleIds = new Set(sorted.map((r) => r.trackingKeywordId));
|
|
||||||
const visibleSelected = new Set(
|
const table = useReactTable({
|
||||||
[...selected].filter((id) => visibleIds.has(id)),
|
data: rows,
|
||||||
);
|
columns,
|
||||||
const visibleSelectedCount = visibleSelected.size;
|
initialState: {
|
||||||
|
sorting: [{ id: defaultSortId, desc: false }],
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getRowId: (row) => row.trackingKeywordId,
|
||||||
|
enableRowSelection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only includes rows that are in the current data (respects parent filtering)
|
||||||
|
const selectedRows = table.getSelectedRowModel().rows;
|
||||||
|
const selectedCount = selectedRows.length;
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (keywordIds: string[]) =>
|
mutationFn: (keywordIds: string[]) =>
|
||||||
removeTrackingKeywords({ data: { projectId, configId, keywordIds } }),
|
removeTrackingKeywords({ data: { projectId, configId, keywordIds } }),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setSelected(new Set());
|
table.resetRowSelection();
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["rankTrackingResults", projectId, configId],
|
queryKey: ["rankTrackingResults", projectId, configId],
|
||||||
@ -75,23 +77,6 @@ export function RankTrackingTable({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleSelect = (id: string) => {
|
|
||||||
setSelected((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
if (visibleSelectedCount === sorted.length && sorted.length > 0) {
|
|
||||||
setSelected(new Set());
|
|
||||||
} else {
|
|
||||||
setSelected(new Set(sorted.map((r) => r.trackingKeywordId)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (resultsLoading) {
|
if (resultsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
@ -100,7 +85,7 @@ export function RankTrackingTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
|
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
|
||||||
{totalCount === 0
|
{totalCount === 0
|
||||||
@ -110,17 +95,14 @@ export function RankTrackingTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSelected =
|
|
||||||
visibleSelectedCount === sorted.length && sorted.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{visibleSelectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
|
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
|
||||||
<span className="text-base-content/70">
|
<span className="text-base-content/70">
|
||||||
{visibleSelectedCount} keyword
|
{selectedCount} keyword
|
||||||
{visibleSelectedCount !== 1 ? "s" : ""} selected
|
{selectedCount !== 1 ? "s" : ""} selected
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn-error btn-xs gap-1"
|
className="btn btn-error btn-xs gap-1"
|
||||||
@ -131,7 +113,7 @@ export function RankTrackingTable({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost btn-xs"
|
className="btn btn-ghost btn-xs"
|
||||||
onClick={() => setSelected(new Set())}
|
onClick={() => table.resetRowSelection()}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@ -143,8 +125,8 @@ export function RankTrackingTable({
|
|||||||
<Modal>
|
<Modal>
|
||||||
<h3 className="text-lg font-semibold">Remove keywords?</h3>
|
<h3 className="text-lg font-semibold">Remove keywords?</h3>
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/70">
|
||||||
This will stop tracking {visibleSelectedCount} keyword
|
This will stop tracking {selectedCount} keyword
|
||||||
{visibleSelectedCount !== 1 ? "s" : ""}. Historical ranking data is
|
{selectedCount !== 1 ? "s" : ""}. Historical ranking data is
|
||||||
preserved but won't appear in the table.
|
preserved but won't appear in the table.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@ -156,14 +138,16 @@ export function RankTrackingTable({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-error btn-sm gap-1"
|
className="btn btn-error btn-sm gap-1"
|
||||||
onClick={() => removeMutation.mutate([...visibleSelected])}
|
onClick={() =>
|
||||||
|
removeMutation.mutate(selectedRows.map((r) => r.id))
|
||||||
|
}
|
||||||
disabled={removeMutation.isPending}
|
disabled={removeMutation.isPending}
|
||||||
>
|
>
|
||||||
{removeMutation.isPending && (
|
{removeMutation.isPending && (
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Loader2 className="size-3 animate-spin" />
|
||||||
)}
|
)}
|
||||||
Remove {visibleSelectedCount} keyword
|
Remove {selectedCount} keyword
|
||||||
{visibleSelectedCount !== 1 ? "s" : ""}
|
{selectedCount !== 1 ? "s" : ""}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@ -172,73 +156,36 @@ export function RankTrackingTable({
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="table table-sm">
|
<table className="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<th className="w-8">
|
<tr key={headerGroup.id}>
|
||||||
<input
|
{headerGroup.headers.map((header) => (
|
||||||
type="checkbox"
|
<th key={header.id}>
|
||||||
className="checkbox checkbox-xs"
|
{header.isPlaceholder
|
||||||
checked={allSelected}
|
? null
|
||||||
onChange={toggleAll}
|
: flexRender(
|
||||||
/>
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
<SortableHeader
|
))}
|
||||||
label="Keyword"
|
|
||||||
field="keyword"
|
|
||||||
currentField={sortField}
|
|
||||||
currentDir={sortDir}
|
|
||||||
onClick={onSort}
|
|
||||||
/>
|
|
||||||
{showDesktop && (
|
|
||||||
<SortableHeader
|
|
||||||
label="Desktop"
|
|
||||||
field="desktopPosition"
|
|
||||||
currentField={sortField}
|
|
||||||
currentDir={sortDir}
|
|
||||||
onClick={onSort}
|
|
||||||
className="min-w-44"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showMobile && (
|
|
||||||
<SortableHeader
|
|
||||||
label="Mobile"
|
|
||||||
field="mobilePosition"
|
|
||||||
currentField={sortField}
|
|
||||||
currentDir={sortDir}
|
|
||||||
onClick={onSort}
|
|
||||||
className="min-w-44"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sorted.map((row) => (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<tr key={row.trackingKeywordId}>
|
<tr key={row.id}>
|
||||||
<td className="w-8">
|
{row.getVisibleCells().map((cell) => (
|
||||||
<input
|
<td key={cell.id} className="align-top">
|
||||||
type="checkbox"
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
className="checkbox checkbox-xs"
|
|
||||||
checked={selected.has(row.trackingKeywordId)}
|
|
||||||
onChange={() => toggleSelect(row.trackingKeywordId)}
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="font-medium">{row.keyword}</td>
|
))}
|
||||||
{showDesktop && (
|
|
||||||
<td className="align-top">
|
|
||||||
<DeviceRankCell result={row.desktop} domain={domain} />
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
{showMobile && (
|
|
||||||
<td className="align-top">
|
|
||||||
<DeviceRankCell result={row.mobile} domain={domain} />
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-base-content/60 pt-2">
|
<p className="text-xs text-base-content/60 pt-2">
|
||||||
{sorted.length} of {totalCount} keywords
|
{rows.length} of {totalCount} keywords
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,45 +7,6 @@ import type {
|
|||||||
RankTrackingRow,
|
RankTrackingRow,
|
||||||
} from "@/types/schemas/rank-tracking";
|
} from "@/types/schemas/rank-tracking";
|
||||||
|
|
||||||
export type SortField = "keyword" | "desktopPosition" | "mobilePosition";
|
|
||||||
export type SortDir = "asc" | "desc";
|
|
||||||
|
|
||||||
const HEADER_TOOLTIPS: Record<string, string> = {
|
|
||||||
keyword: "The search term being tracked",
|
|
||||||
desktopPosition: "Google ranking details on desktop devices",
|
|
||||||
mobilePosition: "Google ranking details on mobile devices",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SortableHeader({
|
|
||||||
label,
|
|
||||||
field,
|
|
||||||
currentField,
|
|
||||||
currentDir,
|
|
||||||
onClick,
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
field: SortField;
|
|
||||||
currentField: SortField;
|
|
||||||
currentDir: SortDir;
|
|
||||||
onClick: (field: SortField) => void;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const isActive = currentField === field;
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
className={`cursor-pointer select-none text-xs uppercase tracking-wide text-base-content/60 hover:text-base-content ${className}`}
|
|
||||||
onClick={() => onClick(field)}
|
|
||||||
title={HEADER_TOOLTIPS[field]}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{isActive && (
|
|
||||||
<span className="ml-1">{currentDir === "asc" ? "↑" : "↓"}</span>
|
|
||||||
)}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PositionBadge({ position }: { position: number | null }) {
|
function PositionBadge({ position }: { position: number | null }) {
|
||||||
if (position === null) {
|
if (position === null) {
|
||||||
return <span className="text-base-content/40">-</span>;
|
return <span className="text-base-content/40">-</span>;
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
comparePositions,
|
|
||||||
type SortField,
|
|
||||||
type SortDir,
|
|
||||||
} from "./RankTrackingTable";
|
|
||||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
|
||||||
|
|
||||||
export function useRankTableSort(
|
|
||||||
rows: RankTrackingRow[],
|
|
||||||
defaultField: SortField,
|
|
||||||
) {
|
|
||||||
const [sortField, setSortField] = useState<SortField>(defaultField);
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSortField(defaultField);
|
|
||||||
}, [defaultField]);
|
|
||||||
|
|
||||||
const sorted = rows.toSorted((a, b) => {
|
|
||||||
const dir = sortDir === "asc" ? 1 : -1;
|
|
||||||
if (sortField === "keyword")
|
|
||||||
return dir * a.keyword.localeCompare(b.keyword);
|
|
||||||
const device = sortField === "desktopPosition" ? "desktop" : "mobile";
|
|
||||||
return dir * comparePositions(a[device].position, b[device].position);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
|
||||||
if (sortField === field) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
|
||||||
else {
|
|
||||||
setSortField(field);
|
|
||||||
setSortDir("asc");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { sorted, sortField, sortDir, handleSort };
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user