Add keyword tags, server-side saved filtering, and filtered exports (#179)
This commit is contained in:
parent
bb08abed92
commit
570fe2aa62
22
drizzle/0013_fat_network.sql
Normal file
22
drizzle/0013_fat_network.sql
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE `saved_keyword_tags` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`normalized_name` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `saved_keyword_tags_project_normalized_name_idx` ON `saved_keyword_tags` (`project_id`,`normalized_name`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `saved_keyword_tags_project_name_idx` ON `saved_keyword_tags` (`project_id`,`name`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `saved_keyword_tag_assignments` (
|
||||||
|
`saved_keyword_id` text NOT NULL,
|
||||||
|
`tag_id` text NOT NULL,
|
||||||
|
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||||
|
FOREIGN KEY (`saved_keyword_id`) REFERENCES `saved_keywords`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`tag_id`) REFERENCES `saved_keyword_tags`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `saved_keyword_tag_assignments_unique_idx` ON `saved_keyword_tag_assignments` (`saved_keyword_id`,`tag_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `saved_keyword_tag_assignments_keyword_idx` ON `saved_keyword_tag_assignments` (`saved_keyword_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `saved_keyword_tag_assignments_tag_idx` ON `saved_keyword_tag_assignments` (`tag_id`);
|
||||||
1
drizzle/0014_tag_color.sql
Normal file
1
drizzle/0014_tag_color.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `saved_keyword_tags` ADD `color` text;
|
||||||
2793
drizzle/meta/0013_snapshot.json
Normal file
2793
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2800
drizzle/meta/0014_snapshot.json
Normal file
2800
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,20 @@
|
|||||||
"when": 1778113978173,
|
"when": 1778113978173,
|
||||||
"tag": "0012_closed_impossible_man",
|
"tag": "0012_closed_impossible_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1778532548655,
|
||||||
|
"tag": "0013_fat_network",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1778750000000,
|
||||||
|
"tag": "0014_tag_color",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -2,12 +2,17 @@ import { scoreTierClass } from "@/client/features/keywords/utils";
|
|||||||
|
|
||||||
export function DifficultyBadge({ value }: { value: number | null }) {
|
export function DifficultyBadge({ value }: { value: number | null }) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return <span className="badge badge-ghost">-</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`score-badge ${scoreTierClass(value)} inline-flex h-6 min-w-6 items-center justify-center rounded-full px-2 text-xs font-semibold`}
|
className={`score-badge ${scoreTierClass(null)} inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums`}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`score-badge ${scoreTierClass(value)} inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -16,7 +16,8 @@ import {
|
|||||||
type SortDir,
|
type SortDir,
|
||||||
type SortField,
|
type SortField,
|
||||||
} from "@/client/features/keywords/components";
|
} from "@/client/features/keywords/components";
|
||||||
import { formatNumber, scoreTierClass } from "@/client/features/keywords/utils";
|
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
|
||||||
|
import { formatNumber } from "@/client/features/keywords/utils";
|
||||||
import type { KeywordResearchRow } from "@/types/keywords";
|
import type { KeywordResearchRow } from "@/types/keywords";
|
||||||
import { EmptyFilterResults } from "./keywordResearchDesktopFilters";
|
import { EmptyFilterResults } from "./keywordResearchDesktopFilters";
|
||||||
|
|
||||||
@ -156,7 +157,7 @@ export function KeywordResearchDesktopTable({
|
|||||||
className="justify-end"
|
className="justify-end"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ getValue }) => <ScoreCell value={getValue()} />,
|
cell: ({ getValue }) => <DifficultyBadge value={getValue()} />,
|
||||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
}),
|
}),
|
||||||
keywordColumnHelper.accessor("intent", {
|
keywordColumnHelper.accessor("intent", {
|
||||||
@ -214,15 +215,3 @@ export function KeywordResearchDesktopTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreCell({ value }: { value: number | null }) {
|
|
||||||
if (value == null) return null;
|
|
||||||
const tierClass = scoreTierClass(value);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`score-badge ${tierClass} inline-flex size-6 items-center justify-center rounded-full text-[10px] font-semibold`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
103
src/client/features/saved-keywords/ManageTagRow.tsx
Normal file
103
src/client/features/saved-keywords/ManageTagRow.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
resolveTagColor,
|
||||||
|
TAG_COLOR_KEYS,
|
||||||
|
tagSwatchClass,
|
||||||
|
type TagColorKey,
|
||||||
|
} from "@/shared/tag-colors";
|
||||||
|
import type { SavedKeywordTagSummary } from "@/types/keywords";
|
||||||
|
|
||||||
|
export function ManageTagRow({
|
||||||
|
tag,
|
||||||
|
isBusy,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
tag: SavedKeywordTagSummary;
|
||||||
|
isBusy: boolean;
|
||||||
|
onSave: (input: { name?: string; color?: TagColorKey | null }) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(tag.name);
|
||||||
|
const currentColor = resolveTagColor(tag);
|
||||||
|
const [color, setColor] = useState<TagColorKey>(currentColor);
|
||||||
|
const nameChanged = name.trim() !== tag.name && name.trim().length > 0;
|
||||||
|
const colorChanged = color !== currentColor;
|
||||||
|
const canSave = (nameChanged || colorChanged) && !isBusy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-y border-base-300 bg-base-200/40 px-3 py-2.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] font-semibold uppercase tracking-wide text-base-content/55">
|
||||||
|
Rename
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Pencil className="size-3 opacity-50" />
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
className="min-w-0 flex-1 rounded border border-base-300 bg-base-100 px-2 py-1 text-sm outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-[11px] font-semibold uppercase tracking-wide text-base-content/55">
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{TAG_COLOR_KEYS.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-label={key}
|
||||||
|
className={`size-5 rounded-full transition ${tagSwatchClass(key)} ${
|
||||||
|
color === key
|
||||||
|
? "ring-2 ring-offset-2 ring-offset-base-200 ring-base-content/40"
|
||||||
|
: "hover:scale-110"
|
||||||
|
}`}
|
||||||
|
onClick={() => setColor(key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-error hover:underline disabled:opacity-50"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isBusy}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded px-2 py-1 text-xs text-base-content/70 hover:bg-base-300"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-content disabled:opacity-50"
|
||||||
|
disabled={!canSave}
|
||||||
|
onClick={() =>
|
||||||
|
onSave({
|
||||||
|
name: nameChanged ? name.trim() : undefined,
|
||||||
|
color: colorChanged ? color : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
FileDown,
|
||||||
|
Loader2,
|
||||||
|
Sheet,
|
||||||
|
Tags,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function SavedKeywordsBulkActionBar({
|
||||||
|
selectedCount,
|
||||||
|
onCopy,
|
||||||
|
onOpenTags,
|
||||||
|
onExportCsv,
|
||||||
|
onExportSheets,
|
||||||
|
onDelete,
|
||||||
|
onClear,
|
||||||
|
exportingSelection,
|
||||||
|
}: {
|
||||||
|
selectedCount: number;
|
||||||
|
onCopy: () => void;
|
||||||
|
onOpenTags: () => void;
|
||||||
|
onExportCsv: () => void;
|
||||||
|
onExportSheets: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
exportingSelection: "csv" | "sheets" | null;
|
||||||
|
}) {
|
||||||
|
if (selectedCount === 0) return null;
|
||||||
|
const exportBusy = exportingSelection != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-30 flex justify-center px-4">
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Bulk actions"
|
||||||
|
className="pointer-events-auto flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-300/85 shadow-2xl backdrop-blur"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-r border-base-content/10 px-3 py-2 text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear selection"
|
||||||
|
className="-ml-1 rounded p-1 text-base-content/55 hover:bg-base-content/10 hover:text-base-content"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="font-medium tabular-nums">{selectedCount}</span>
|
||||||
|
<span className="text-base-content/60">selected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5 px-1.5">
|
||||||
|
<ActionButton
|
||||||
|
icon={<Tags className="size-3.5" />}
|
||||||
|
onClick={onOpenTags}
|
||||||
|
>
|
||||||
|
Tag
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
|
<div className="dropdown dropdown-top dropdown-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
|
disabled={exportBusy}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-base-content/85 hover:bg-base-content/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{exportBusy ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
<ChevronDown className="size-3 opacity-60" />
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
tabIndex={0}
|
||||||
|
role="menu"
|
||||||
|
className="dropdown-content menu z-10 mb-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<button type="button" onClick={onCopy}>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
Copy keywords
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExportSheets}
|
||||||
|
disabled={exportBusy}
|
||||||
|
>
|
||||||
|
<Sheet className="size-4" />
|
||||||
|
Export to Sheets
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExportCsv}
|
||||||
|
disabled={exportBusy}
|
||||||
|
>
|
||||||
|
<FileDown className="size-4" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center border-l border-base-content/10 px-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-error hover:bg-error/10"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-base-content/85 hover:bg-base-content/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,322 @@
|
|||||||
|
import { Check, Loader2, Plus, Search, X } from "lucide-react";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { Modal } from "@/client/components/Modal";
|
||||||
|
import { resolveTagColor, tagDotClass } from "@/shared/tag-colors";
|
||||||
|
import type { SavedKeywordTag, SavedKeywordTagSummary } from "@/types/keywords";
|
||||||
|
import { TagChip } from "./TagChip";
|
||||||
|
|
||||||
|
type Mode = "add" | "remove";
|
||||||
|
|
||||||
|
export function SavedKeywordsBulkTagsModal({
|
||||||
|
availableTags,
|
||||||
|
selectedCount,
|
||||||
|
selectedRowTags,
|
||||||
|
isPending,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
availableTags: SavedKeywordTagSummary[];
|
||||||
|
selectedCount: number;
|
||||||
|
/** Tags currently attached to the selected rows (deduped). Used to show
|
||||||
|
* initial state and to compute which existing tags can be removed. */
|
||||||
|
selectedRowTags: SavedKeywordTag[];
|
||||||
|
isPending: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onApply: (input: { addTags?: string[]; removeTagIds?: string[] }) => void;
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<Mode>("add");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [addNames, setAddNames] = useState<string[]>([]);
|
||||||
|
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const normalizedAddSet = useMemo(
|
||||||
|
() => new Set(addNames.map((name) => name.toLocaleLowerCase())),
|
||||||
|
[addNames],
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableByNormalized = useMemo(() => {
|
||||||
|
const map = new Map<string, SavedKeywordTagSummary>();
|
||||||
|
for (const tag of availableTags) {
|
||||||
|
map.set(tag.normalizedName, tag);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [availableTags]);
|
||||||
|
|
||||||
|
const filteredAvailable = useMemo(() => {
|
||||||
|
const q = query.trim().toLocaleLowerCase();
|
||||||
|
if (!q) return availableTags;
|
||||||
|
return availableTags.filter((tag) => tag.normalizedName.includes(q));
|
||||||
|
}, [availableTags, query]);
|
||||||
|
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
const queryNormalized = trimmedQuery.toLocaleLowerCase();
|
||||||
|
const showCreate =
|
||||||
|
mode === "add" &&
|
||||||
|
trimmedQuery.length > 0 &&
|
||||||
|
!availableByNormalized.has(queryNormalized) &&
|
||||||
|
!normalizedAddSet.has(queryNormalized);
|
||||||
|
|
||||||
|
const canApply = !isPending && (addNames.length > 0 || removeIds.length > 0);
|
||||||
|
|
||||||
|
const handleToggleAdd = (tag: SavedKeywordTagSummary) => {
|
||||||
|
setAddNames((current) =>
|
||||||
|
normalizedAddSet.has(tag.normalizedName)
|
||||||
|
? current.filter(
|
||||||
|
(name) => name.toLocaleLowerCase() !== tag.normalizedName,
|
||||||
|
)
|
||||||
|
: [...current, tag.name],
|
||||||
|
);
|
||||||
|
setRemoveIds((current) => current.filter((id) => id !== tag.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!trimmedQuery) return;
|
||||||
|
setAddNames((current) =>
|
||||||
|
current.some((name) => name.toLocaleLowerCase() === queryNormalized)
|
||||||
|
? current
|
||||||
|
: [...current, trimmedQuery],
|
||||||
|
);
|
||||||
|
setQuery("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleRemove = (tag: SavedKeywordTag) => {
|
||||||
|
setRemoveIds((current) =>
|
||||||
|
current.includes(tag.id)
|
||||||
|
? current.filter((id) => id !== tag.id)
|
||||||
|
: [...current, tag.id],
|
||||||
|
);
|
||||||
|
setAddNames((current) =>
|
||||||
|
current.filter((name) => name.toLocaleLowerCase() !== tag.normalizedName),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal maxWidth="max-w-lg" onClose={onClose} labelledBy="bulk-tags-title">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 id="bulk-tags-title" className="text-lg font-semibold">
|
||||||
|
Update tags
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-base-content/65">
|
||||||
|
Apply or remove tags across {selectedCount} selected keyword
|
||||||
|
{selectedCount !== 1 ? "s" : ""}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-flex rounded-md border border-base-300 bg-base-200/40 p-0.5 text-sm">
|
||||||
|
<SegmentButton
|
||||||
|
active={mode === "add"}
|
||||||
|
onClick={() => setMode("add")}
|
||||||
|
label="Add tags"
|
||||||
|
count={addNames.length}
|
||||||
|
/>
|
||||||
|
<SegmentButton
|
||||||
|
active={mode === "remove"}
|
||||||
|
onClick={() => setMode("remove")}
|
||||||
|
label="Remove tags"
|
||||||
|
count={removeIds.length}
|
||||||
|
disabled={selectedRowTags.length === 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "add" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{addNames.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-base-300 bg-base-200/40 px-2 py-2">
|
||||||
|
{addNames.map((name) => {
|
||||||
|
const existing = availableByNormalized.get(
|
||||||
|
name.toLocaleLowerCase(),
|
||||||
|
);
|
||||||
|
const tag = existing ?? {
|
||||||
|
id: `new:${name}`,
|
||||||
|
name,
|
||||||
|
normalizedName: name.toLocaleLowerCase(),
|
||||||
|
color: null,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<TagChip
|
||||||
|
key={name}
|
||||||
|
tag={tag}
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setAddNames((current) =>
|
||||||
|
current.filter(
|
||||||
|
(existingName) => existingName !== name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trailing={<X className="size-3 opacity-70" />}
|
||||||
|
title="Remove from selection"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 rounded-md border border-base-300 bg-base-100 px-2 py-2">
|
||||||
|
<Search className="size-3.5 opacity-50" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" && showCreate) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search or create…"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-base-content/40"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="max-h-56 overflow-y-auto rounded-md border border-base-300">
|
||||||
|
{showCreate ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-base-200"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 text-primary" />
|
||||||
|
<span className="text-base-content/70">Create</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
“{trimmedQuery}”
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filteredAvailable.length === 0 && !showCreate ? (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-base-content/55">
|
||||||
|
{availableTags.length === 0
|
||||||
|
? "No tags yet. Type a name above to create one."
|
||||||
|
: "No tags match that search."}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filteredAvailable.map((tag) => {
|
||||||
|
const checked = normalizedAddSet.has(tag.normalizedName);
|
||||||
|
const color = resolveTagColor(tag);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleAdd(tag)}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-base-200"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex size-4 shrink-0 items-center justify-center rounded border ${
|
||||||
|
checked
|
||||||
|
? "border-primary bg-primary text-primary-content"
|
||||||
|
: "border-base-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{checked ? <Check className="size-3" /> : null}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`size-2 shrink-0 rounded-full ${tagDotClass(color)}`}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate text-sm">{tag.name}</span>
|
||||||
|
<span className="text-[11px] tabular-nums text-base-content/45">
|
||||||
|
{tag.keywordCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedRowTags.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-base-300 bg-base-200/40 px-3 py-6 text-center text-xs text-base-content/55">
|
||||||
|
The selected keywords don't have any tags to remove.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1.5 rounded-md border border-base-300 p-3">
|
||||||
|
{selectedRowTags.map((tag) => {
|
||||||
|
const checked = removeIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<TagChip
|
||||||
|
key={tag.id}
|
||||||
|
tag={tag}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToggleRemove(tag)}
|
||||||
|
selected={checked}
|
||||||
|
trailing={checked ? <Check className="size-3" /> : null}
|
||||||
|
title={checked ? "Will be removed" : "Click to remove"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{removeIds.length > 0 ? (
|
||||||
|
<p className="text-xs text-base-content/55">
|
||||||
|
{removeIds.length} tag{removeIds.length !== 1 ? "s" : ""} will
|
||||||
|
be detached from the selected keywords.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md px-3 py-1.5 text-sm text-base-content/70 hover:bg-base-200"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-content disabled:opacity-50"
|
||||||
|
disabled={!canApply}
|
||||||
|
onClick={() =>
|
||||||
|
onApply({
|
||||||
|
addTags: addNames.length > 0 ? addNames : undefined,
|
||||||
|
removeTagIds: removeIds.length > 0 ? removeIds : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded px-3 py-1 text-sm transition ${
|
||||||
|
active
|
||||||
|
? "bg-base-100 font-medium shadow-sm"
|
||||||
|
: "text-base-content/65 hover:text-base-content"
|
||||||
|
} disabled:opacity-40`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{count > 0 ? (
|
||||||
|
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-content">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/client/features/saved-keywords/SavedKeywordsFilterPanel.tsx
Normal file
282
src/client/features/saved-keywords/SavedKeywordsFilterPanel.tsx
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import { Minus, Plus, RotateCcw, X } from "lucide-react";
|
||||||
|
import { useState, type KeyboardEvent } from "react";
|
||||||
|
import type { SavedKeywordsFilterValues } from "./savedKeywordsFilterTypes";
|
||||||
|
import type { SavedKeywordsFilterForm } from "./useSavedKeywordsFilters";
|
||||||
|
|
||||||
|
export function SavedKeywordsFilterPanel({
|
||||||
|
form,
|
||||||
|
activeFilterCount,
|
||||||
|
onReset,
|
||||||
|
}: {
|
||||||
|
form: SavedKeywordsFilterForm;
|
||||||
|
activeFilterCount: number;
|
||||||
|
onReset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold">Refine results</p>
|
||||||
|
{activeFilterCount > 0 ? (
|
||||||
|
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||||
|
{activeFilterCount} active
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-xs btn-ghost gap-1"
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={activeFilterCount === 0}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3" />
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||||
|
<TermsTokenInput
|
||||||
|
form={form}
|
||||||
|
name="include"
|
||||||
|
label="Include"
|
||||||
|
variant="include"
|
||||||
|
placeholder="Must contain… e.g. audit"
|
||||||
|
/>
|
||||||
|
<TermsTokenInput
|
||||||
|
form={form}
|
||||||
|
name="exclude"
|
||||||
|
label="Exclude"
|
||||||
|
variant="exclude"
|
||||||
|
placeholder="Must not contain… e.g. jobs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||||
|
<FilterRangeInputs
|
||||||
|
form={form}
|
||||||
|
title="Search Volume"
|
||||||
|
minName="minVol"
|
||||||
|
maxName="maxVol"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<FilterRangeInputs
|
||||||
|
form={form}
|
||||||
|
title="CPC (USD)"
|
||||||
|
minName="minCpc"
|
||||||
|
maxName="maxCpc"
|
||||||
|
step="0.01"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<FilterRangeInputs
|
||||||
|
form={form}
|
||||||
|
title="Difficulty"
|
||||||
|
minName="minKd"
|
||||||
|
maxName="maxKd"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TermsVariant = "include" | "exclude";
|
||||||
|
|
||||||
|
const VARIANT_STYLES: Record<
|
||||||
|
TermsVariant,
|
||||||
|
{ icon: typeof Plus; chip: string; iconBg: string }
|
||||||
|
> = {
|
||||||
|
include: {
|
||||||
|
icon: Plus,
|
||||||
|
chip: "tag-chip-emerald ring-1 ring-inset",
|
||||||
|
iconBg: "tag-chip-emerald ring-1 ring-inset",
|
||||||
|
},
|
||||||
|
exclude: {
|
||||||
|
icon: Minus,
|
||||||
|
chip: "tag-chip-rose ring-1 ring-inset",
|
||||||
|
iconBg: "tag-chip-rose ring-1 ring-inset",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function splitTerms(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.split(/[,+]/)
|
||||||
|
.map((term) => term.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinTerms(terms: string[]): string {
|
||||||
|
return terms.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function TermsTokenInput({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
variant,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
form: SavedKeywordsFilterForm;
|
||||||
|
name: "include" | "exclude";
|
||||||
|
label: string;
|
||||||
|
variant: TermsVariant;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const styles = VARIANT_STYLES[variant];
|
||||||
|
const Icon = styles.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-lg border border-base-300 bg-base-100 p-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex size-4 items-center justify-center rounded ${styles.iconBg}`}
|
||||||
|
>
|
||||||
|
<Icon className="size-2.5" />
|
||||||
|
</span>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form.Field name={name}>
|
||||||
|
{(field) => {
|
||||||
|
const terms = splitTerms(field.state.value);
|
||||||
|
const commit = (next: string[]) => {
|
||||||
|
field.handleChange(joinTerms([...new Set(next)]));
|
||||||
|
};
|
||||||
|
const addFromDraft = () => {
|
||||||
|
const parsed = splitTerms(draft);
|
||||||
|
if (parsed.length > 0) {
|
||||||
|
commit([...terms, ...parsed]);
|
||||||
|
setDraft("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "Enter" || event.key === ",") {
|
||||||
|
event.preventDefault();
|
||||||
|
addFromDraft();
|
||||||
|
} else if (
|
||||||
|
event.key === "Backspace" &&
|
||||||
|
draft.length === 0 &&
|
||||||
|
terms.length > 0
|
||||||
|
) {
|
||||||
|
commit(terms.slice(0, -1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-base-300 bg-base-200/30 px-2 py-1.5 focus-within:border-primary">
|
||||||
|
{terms.map((term) => (
|
||||||
|
<span
|
||||||
|
key={term}
|
||||||
|
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs ${styles.chip}`}
|
||||||
|
>
|
||||||
|
{term}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="opacity-70 hover:opacity-100"
|
||||||
|
aria-label={`Remove ${term}`}
|
||||||
|
onClick={() =>
|
||||||
|
commit(terms.filter((existing) => existing !== term))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={addFromDraft}
|
||||||
|
placeholder={terms.length === 0 ? placeholder : ""}
|
||||||
|
className="min-w-[6rem] flex-1 bg-transparent text-xs outline-none placeholder:text-base-content/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RangeFieldName = Extract<
|
||||||
|
keyof SavedKeywordsFilterValues,
|
||||||
|
"minVol" | "maxVol" | "minCpc" | "maxCpc" | "minKd" | "maxKd"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function FilterRangeInputs({
|
||||||
|
form,
|
||||||
|
title,
|
||||||
|
minName,
|
||||||
|
maxName,
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
}: {
|
||||||
|
form: SavedKeywordsFilterForm;
|
||||||
|
title: string;
|
||||||
|
minName: Extract<RangeFieldName, "minVol" | "minCpc" | "minKd">;
|
||||||
|
maxName: Extract<RangeFieldName, "maxVol" | "maxCpc" | "maxKd">;
|
||||||
|
step?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded-lg border border-base-300 bg-base-100 p-2.5">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<CompactRangeInput
|
||||||
|
form={form}
|
||||||
|
name={minName}
|
||||||
|
placeholder="Min"
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
/>
|
||||||
|
<CompactRangeInput
|
||||||
|
form={form}
|
||||||
|
name={maxName}
|
||||||
|
placeholder="Max"
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactRangeInput({
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
placeholder,
|
||||||
|
step,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
}: {
|
||||||
|
form: SavedKeywordsFilterForm;
|
||||||
|
name: RangeFieldName;
|
||||||
|
placeholder: string;
|
||||||
|
step?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<form.Field name={name}>
|
||||||
|
{(field) => (
|
||||||
|
<input
|
||||||
|
className="input input-bordered input-xs bg-base-100"
|
||||||
|
placeholder={placeholder}
|
||||||
|
type="number"
|
||||||
|
step={step}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/client/features/saved-keywords/SavedKeywordsFilters.tsx
Normal file
76
src/client/features/saved-keywords/SavedKeywordsFilters.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { SlidersHorizontal } from "lucide-react";
|
||||||
|
import { SavedKeywordsFilterPanel } from "./SavedKeywordsFilterPanel";
|
||||||
|
import { SavedKeywordsTagFilter } from "./SavedKeywordsTagFilter";
|
||||||
|
import type { TagColorKey } from "@/shared/tag-colors";
|
||||||
|
import type { SavedKeywordTagSummary } from "@/types/keywords";
|
||||||
|
import type { SavedKeywordsFilterForm } from "./useSavedKeywordsFilters";
|
||||||
|
|
||||||
|
export function SavedKeywordsFilters({
|
||||||
|
filtersForm,
|
||||||
|
activeFilterCount,
|
||||||
|
showFilters,
|
||||||
|
onToggleFilters,
|
||||||
|
onResetAllFilters,
|
||||||
|
availableTags,
|
||||||
|
selectedTagIds,
|
||||||
|
busyTagIds,
|
||||||
|
onToggleTagFilter,
|
||||||
|
onClearTagSelection,
|
||||||
|
onUpdateTag,
|
||||||
|
onDeleteTag,
|
||||||
|
}: {
|
||||||
|
filtersForm: SavedKeywordsFilterForm;
|
||||||
|
activeFilterCount: number;
|
||||||
|
showFilters: boolean;
|
||||||
|
onToggleFilters: () => void;
|
||||||
|
onResetAllFilters: () => void;
|
||||||
|
availableTags: SavedKeywordTagSummary[];
|
||||||
|
selectedTagIds: string[];
|
||||||
|
busyTagIds: Set<string>;
|
||||||
|
onToggleTagFilter: (tagId: string) => void;
|
||||||
|
onClearTagSelection: () => void;
|
||||||
|
onUpdateTag: (input: {
|
||||||
|
tagId: string;
|
||||||
|
name?: string;
|
||||||
|
color?: TagColorKey | null;
|
||||||
|
}) => void;
|
||||||
|
onDeleteTag: (tagId: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-base-300 px-4 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||||
|
onClick={onToggleFilters}
|
||||||
|
title="Toggle table filters"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="size-3.5" />
|
||||||
|
Filters
|
||||||
|
{activeFilterCount > 0 ? (
|
||||||
|
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
<SavedKeywordsTagFilter
|
||||||
|
availableTags={availableTags}
|
||||||
|
selectedTagIds={selectedTagIds}
|
||||||
|
busyTagIds={busyTagIds}
|
||||||
|
onToggleTagFilter={onToggleTagFilter}
|
||||||
|
onClearSelection={onClearTagSelection}
|
||||||
|
onUpdateTag={onUpdateTag}
|
||||||
|
onDeleteTag={onDeleteTag}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilters ? (
|
||||||
|
<SavedKeywordsFilterPanel
|
||||||
|
form={filtersForm}
|
||||||
|
activeFilterCount={activeFilterCount}
|
||||||
|
onReset={onResetAllFilters}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/client/features/saved-keywords/SavedKeywordsHeader.tsx
Normal file
63
src/client/features/saved-keywords/SavedKeywordsHeader.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { ChevronDown, Download, FileDown, Loader2, Sheet } from "lucide-react";
|
||||||
|
|
||||||
|
export function SavedKeywordsHeader({
|
||||||
|
totalCount,
|
||||||
|
exporting,
|
||||||
|
onExportCsv,
|
||||||
|
onExportSheets,
|
||||||
|
}: {
|
||||||
|
totalCount: number;
|
||||||
|
exporting: "csv" | "sheets" | null;
|
||||||
|
onExportCsv: () => void;
|
||||||
|
onExportSheets: () => void;
|
||||||
|
}) {
|
||||||
|
const disabled = totalCount === 0 || exporting != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Saved Keywords</h1>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
Save keyword ideas from research, organize them with tags, and revisit
|
||||||
|
when you're ready to act.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
className={`btn btn-ghost btn-sm gap-1.5 ${disabled ? "btn-disabled" : ""}`}
|
||||||
|
>
|
||||||
|
{exporting != null ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="size-4" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
<ChevronDown className="size-3 opacity-60" />
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
tabIndex={0}
|
||||||
|
role="menu"
|
||||||
|
className="dropdown-content menu z-10 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<button type="button" onClick={onExportSheets} disabled={disabled}>
|
||||||
|
<Sheet className="size-4" />
|
||||||
|
Export to Sheets
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" onClick={onExportCsv} disabled={disabled}>
|
||||||
|
<FileDown className="size-4" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
src/client/features/saved-keywords/SavedKeywordsModals.tsx
Normal file
54
src/client/features/saved-keywords/SavedKeywordsModals.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { Modal } from "@/client/components/Modal";
|
||||||
|
|
||||||
|
export function RemoveSavedKeywordsError({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
|
||||||
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteSavedKeywordsModal({
|
||||||
|
selectedCount,
|
||||||
|
isPending,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
selectedCount: number;
|
||||||
|
isPending: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} labelledBy="delete-keywords-title">
|
||||||
|
<h3 id="delete-keywords-title" className="text-lg font-semibold">
|
||||||
|
Delete keywords?
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
This will permanently delete {selectedCount} saved keyword
|
||||||
|
{selectedCount !== 1 ? "s" : ""}.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-error btn-sm gap-1"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||||
|
Delete {selectedCount} keyword
|
||||||
|
{selectedCount !== 1 ? "s" : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import { SAVED_KEYWORD_PAGE_SIZES } from "./savedKeywordsUtils";
|
||||||
|
|
||||||
|
export function SavedKeywordsPagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
isLoading,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: (typeof SAVED_KEYWORD_PAGE_SIZES)[number];
|
||||||
|
totalCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (
|
||||||
|
pageSize: (typeof SAVED_KEYWORD_PAGE_SIZES)[number],
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
const start = totalCount === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
|
const end = Math.min(totalCount, page * pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-t border-base-300 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm tabular-nums text-base-content/70">
|
||||||
|
<span>
|
||||||
|
{start.toLocaleString()}-{end.toLocaleString()} of{" "}
|
||||||
|
{totalCount.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{isLoading ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-base-content/70">
|
||||||
|
<span className="whitespace-nowrap">Rows per page</span>
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm w-20"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event) =>
|
||||||
|
onPageSizeChange(parsePageSize(event.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{SAVED_KEYWORD_PAGE_SIZES.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="whitespace-nowrap text-sm tabular-nums text-base-content/70">
|
||||||
|
Page {page.toLocaleString()} of {totalPages.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm btn-square"
|
||||||
|
disabled={page <= 1 || isLoading}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm btn-square"
|
||||||
|
disabled={page >= totalPages || isLoading}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePageSize(
|
||||||
|
value: string,
|
||||||
|
): (typeof SAVED_KEYWORD_PAGE_SIZES)[number] {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return SAVED_KEYWORD_PAGE_SIZES.find((size) => size === parsed) ?? 50;
|
||||||
|
}
|
||||||
19
src/client/features/saved-keywords/SavedKeywordsStatus.tsx
Normal file
19
src/client/features/saved-keywords/SavedKeywordsStatus.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function SavedKeywordsStatus({
|
||||||
|
totalCount,
|
||||||
|
isFetching,
|
||||||
|
}: {
|
||||||
|
totalCount: number;
|
||||||
|
isFetching: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-1 text-xs text-base-content/60">
|
||||||
|
<span>
|
||||||
|
{totalCount.toLocaleString()} saved keyword
|
||||||
|
{totalCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
{isFetching ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/client/features/saved-keywords/SavedKeywordsTable.tsx
Normal file
203
src/client/features/saved-keywords/SavedKeywordsTable.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
createColumnHelper,
|
||||||
|
type ColumnDef,
|
||||||
|
type OnChangeFn,
|
||||||
|
type RowSelectionState,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
AppDataTable,
|
||||||
|
makeSelectionColumn,
|
||||||
|
useAppTable,
|
||||||
|
useSelectionAnchor,
|
||||||
|
} from "@/client/components/table/AppDataTable";
|
||||||
|
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||||
|
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
|
||||||
|
import { IntentBadge } from "@/client/features/keywords/components";
|
||||||
|
import type { KeywordIntent, SavedKeywordRow } from "@/types/keywords";
|
||||||
|
import { TagChip } from "./TagChip";
|
||||||
|
import {
|
||||||
|
formatSavedKeywordDate,
|
||||||
|
formatSavedKeywordNumber,
|
||||||
|
} from "./savedKeywordsUtils";
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<SavedKeywordRow>();
|
||||||
|
|
||||||
|
export function SavedKeywordsTable({
|
||||||
|
rows,
|
||||||
|
rowSelection,
|
||||||
|
sorting,
|
||||||
|
isLoading,
|
||||||
|
hasActiveFilters,
|
||||||
|
onRowSelectionChange,
|
||||||
|
onSortingChange,
|
||||||
|
}: {
|
||||||
|
rows: SavedKeywordRow[];
|
||||||
|
rowSelection: RowSelectionState;
|
||||||
|
sorting: SortingState;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasActiveFilters: boolean;
|
||||||
|
onRowSelectionChange: OnChangeFn<RowSelectionState>;
|
||||||
|
onSortingChange: OnChangeFn<SortingState>;
|
||||||
|
}) {
|
||||||
|
const selectAnchorRef = useSelectionAnchor();
|
||||||
|
const columns = useMemo<ColumnDef<SavedKeywordRow>[]>(
|
||||||
|
() => [
|
||||||
|
makeSelectionColumn<SavedKeywordRow>(selectAnchorRef),
|
||||||
|
columnHelper.accessor("keyword", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} label="Keyword" />
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="font-medium">{getValue()}</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("searchVolume", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} label="Volume" />
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => formatSavedKeywordNumber(getValue()),
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("cpc", {
|
||||||
|
header: ({ column }) => <SortableHeader column={column} label="CPC" />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return value == null ? "-" : `$${value.toFixed(2)}`;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("competition", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Competition"
|
||||||
|
helpText="Advertiser competition."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const value = getValue();
|
||||||
|
return value == null ? "-" : value.toFixed(2);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("keywordDifficulty", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader
|
||||||
|
column={column}
|
||||||
|
label="Difficulty"
|
||||||
|
helpText="Keyword difficulty score."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => <DifficultyBadge value={getValue()} />,
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("intent", {
|
||||||
|
header: () => "Intent",
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<IntentBadge intent={normalizeIntent(getValue())} />
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "tags",
|
||||||
|
header: () => "Tags",
|
||||||
|
cell: ({ row }) => <TagList tags={row.original.tags} />,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { cellClassName: "min-w-40 max-w-64" },
|
||||||
|
}),
|
||||||
|
columnHelper.accessor("fetchedAt", {
|
||||||
|
header: ({ column }) => (
|
||||||
|
<SortableHeader column={column} label="Last Fetched" />
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<span className="text-xs text-base-content/55">
|
||||||
|
{formatSavedKeywordDate(getValue())}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[selectAnchorRef],
|
||||||
|
);
|
||||||
|
const table = useAppTable({
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
state: { rowSelection, sorting },
|
||||||
|
onRowSelectionChange,
|
||||||
|
onSortingChange,
|
||||||
|
getRowId: (row) => row.id,
|
||||||
|
enableRowSelection: true,
|
||||||
|
manualSorting: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppDataTable
|
||||||
|
table={table}
|
||||||
|
className="table table-zebra table-sm"
|
||||||
|
isLoading={isLoading}
|
||||||
|
loading={<SavedKeywordsSkeleton />}
|
||||||
|
empty={<SavedKeywordsEmptyState hasActiveFilters={hasActiveFilters} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIntent(value: string | null): KeywordIntent {
|
||||||
|
switch (value) {
|
||||||
|
case "informational":
|
||||||
|
case "commercial":
|
||||||
|
case "transactional":
|
||||||
|
case "navigational":
|
||||||
|
case "unknown":
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagList({ tags }: { tags: SavedKeywordRow["tags"] }) {
|
||||||
|
if (tags.length === 0) {
|
||||||
|
return <span className="text-base-content/35">-</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<TagChip key={tag.id} tag={tag} size="xs" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavedKeywordsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" aria-busy>
|
||||||
|
<div className="skeleton h-4 w-48" />
|
||||||
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
|
<div key={index} className="grid grid-cols-9 items-center gap-3">
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton col-span-2 h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
<div className="skeleton h-4" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SavedKeywordsEmptyState({
|
||||||
|
hasActiveFilters,
|
||||||
|
}: {
|
||||||
|
hasActiveFilters: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-sm text-base-content/55">
|
||||||
|
<Search className="mx-auto mb-2 size-8 opacity-40" />
|
||||||
|
<p>
|
||||||
|
{hasActiveFilters
|
||||||
|
? "No saved keywords match the current filters."
|
||||||
|
: "No saved keywords yet. Use the Keyword Research page to find and save keywords."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
318
src/client/features/saved-keywords/SavedKeywordsTagFilter.tsx
Normal file
318
src/client/features/saved-keywords/SavedKeywordsTagFilter.tsx
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
MoreHorizontal,
|
||||||
|
Search,
|
||||||
|
Tag as TagIcon,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
resolveTagColor,
|
||||||
|
tagDotClass,
|
||||||
|
type TagColorKey,
|
||||||
|
} from "@/shared/tag-colors";
|
||||||
|
import type { SavedKeywordTagSummary } from "@/types/keywords";
|
||||||
|
import { ManageTagRow } from "./ManageTagRow";
|
||||||
|
import { TagChip } from "./TagChip";
|
||||||
|
|
||||||
|
export function SavedKeywordsTagFilter({
|
||||||
|
availableTags,
|
||||||
|
selectedTagIds,
|
||||||
|
onToggleTagFilter,
|
||||||
|
onClearSelection,
|
||||||
|
onUpdateTag,
|
||||||
|
onDeleteTag,
|
||||||
|
busyTagIds,
|
||||||
|
}: {
|
||||||
|
availableTags: SavedKeywordTagSummary[];
|
||||||
|
selectedTagIds: string[];
|
||||||
|
onToggleTagFilter: (tagId: string) => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onUpdateTag: (input: {
|
||||||
|
tagId: string;
|
||||||
|
name?: string;
|
||||||
|
color?: TagColorKey | null;
|
||||||
|
}) => void;
|
||||||
|
onDeleteTag: (tagId: string) => void;
|
||||||
|
busyTagIds: Set<string>;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [managingTagId, setManagingTagId] = useState<string | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target instanceof Node &&
|
||||||
|
containerRef.current &&
|
||||||
|
containerRef.current.contains(target)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
setManagingTagId(null);
|
||||||
|
};
|
||||||
|
const handleKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
setManagingTagId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClick);
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClick);
|
||||||
|
document.removeEventListener("keydown", handleKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const filteredTags = useMemo(() => {
|
||||||
|
const q = query.trim().toLocaleLowerCase();
|
||||||
|
if (!q) return availableTags;
|
||||||
|
return availableTags.filter((tag) => tag.normalizedName.includes(q));
|
||||||
|
}, [availableTags, query]);
|
||||||
|
|
||||||
|
const selectedTags = availableTags.filter((tag) =>
|
||||||
|
selectedTagIds.includes(tag.id),
|
||||||
|
);
|
||||||
|
const hasSelection = selectedTagIds.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition ${
|
||||||
|
hasSelection
|
||||||
|
? "border-primary/50 bg-primary/10 text-base-content"
|
||||||
|
: "border-base-300 bg-base-100 hover:border-base-content/30"
|
||||||
|
}`}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<TagIcon className="size-3.5 opacity-70" />
|
||||||
|
<span className="font-medium">Tags</span>
|
||||||
|
{hasSelection ? (
|
||||||
|
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold text-primary-content">
|
||||||
|
{selectedTags.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<ChevronDown className="size-3.5 opacity-60" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{selectedTags.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
{selectedTags.map((tag) => (
|
||||||
|
<TagChip
|
||||||
|
key={tag.id}
|
||||||
|
tag={tag}
|
||||||
|
size="sm"
|
||||||
|
selected
|
||||||
|
onClick={() => onToggleTagFilter(tag.id)}
|
||||||
|
trailing={<X className="size-3 opacity-70" />}
|
||||||
|
title="Remove filter"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-base-content/60 underline-offset-2 hover:text-base-content hover:underline"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<TagFilterPopover
|
||||||
|
availableTags={availableTags}
|
||||||
|
filteredTags={filteredTags}
|
||||||
|
selectedTagIds={selectedTagIds}
|
||||||
|
query={query}
|
||||||
|
managingTagId={managingTagId}
|
||||||
|
busyTagIds={busyTagIds}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
onToggleTagFilter={onToggleTagFilter}
|
||||||
|
onStartManaging={setManagingTagId}
|
||||||
|
onUpdateTag={(tagId, input) => {
|
||||||
|
onUpdateTag({ tagId, ...input });
|
||||||
|
setManagingTagId(null);
|
||||||
|
}}
|
||||||
|
onDeleteTag={(tagId) => {
|
||||||
|
onDeleteTag(tagId);
|
||||||
|
setManagingTagId(null);
|
||||||
|
}}
|
||||||
|
onClearSelection={onClearSelection}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagFilterPopover({
|
||||||
|
availableTags,
|
||||||
|
filteredTags,
|
||||||
|
selectedTagIds,
|
||||||
|
query,
|
||||||
|
managingTagId,
|
||||||
|
busyTagIds,
|
||||||
|
onQueryChange,
|
||||||
|
onToggleTagFilter,
|
||||||
|
onStartManaging,
|
||||||
|
onUpdateTag,
|
||||||
|
onDeleteTag,
|
||||||
|
onClearSelection,
|
||||||
|
}: {
|
||||||
|
availableTags: SavedKeywordTagSummary[];
|
||||||
|
filteredTags: SavedKeywordTagSummary[];
|
||||||
|
selectedTagIds: string[];
|
||||||
|
query: string;
|
||||||
|
managingTagId: string | null;
|
||||||
|
busyTagIds: Set<string>;
|
||||||
|
onQueryChange: (value: string) => void;
|
||||||
|
onToggleTagFilter: (tagId: string) => void;
|
||||||
|
onStartManaging: (tagId: string | null) => void;
|
||||||
|
onUpdateTag: (
|
||||||
|
tagId: string,
|
||||||
|
input: { name?: string; color?: TagColorKey | null },
|
||||||
|
) => void;
|
||||||
|
onDeleteTag: (tagId: string) => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="absolute right-0 top-full z-20 mt-2 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-2xl">
|
||||||
|
<div className="border-b border-base-300 p-2">
|
||||||
|
<label className="flex items-center gap-2 rounded-md border border-base-300 bg-base-200/50 px-2 py-1.5">
|
||||||
|
<Search className="size-3.5 opacity-50" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => onQueryChange(event.target.value)}
|
||||||
|
placeholder="Search tags…"
|
||||||
|
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-base-content/40"
|
||||||
|
/>
|
||||||
|
{query ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-base-content/40 hover:text-base-content"
|
||||||
|
onClick={() => onQueryChange("")}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-72 overflow-y-auto py-1">
|
||||||
|
{filteredTags.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-base-content/55">
|
||||||
|
{availableTags.length === 0
|
||||||
|
? "No tags yet. Add tags from a selection of keywords."
|
||||||
|
: "No tags match that search."}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{filteredTags.map((tag) => (
|
||||||
|
<TagFilterRow
|
||||||
|
key={tag.id}
|
||||||
|
tag={tag}
|
||||||
|
checked={selectedTagIds.includes(tag.id)}
|
||||||
|
isManaging={managingTagId === tag.id}
|
||||||
|
isBusy={busyTagIds.has(tag.id)}
|
||||||
|
onToggle={() => onToggleTagFilter(tag.id)}
|
||||||
|
onStartManaging={onStartManaging}
|
||||||
|
onUpdate={(input) => onUpdateTag(tag.id, input)}
|
||||||
|
onDelete={() => onDeleteTag(tag.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTagIds.length > 0 ? (
|
||||||
|
<div className="flex items-center justify-between border-t border-base-300 px-2 py-1.5 text-xs">
|
||||||
|
<span className="text-base-content/55">
|
||||||
|
{selectedTagIds.length} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded px-2 py-1 text-base-content/70 hover:bg-base-200"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagFilterRow({
|
||||||
|
tag,
|
||||||
|
checked,
|
||||||
|
isManaging,
|
||||||
|
isBusy,
|
||||||
|
onToggle,
|
||||||
|
onStartManaging,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
tag: SavedKeywordTagSummary;
|
||||||
|
checked: boolean;
|
||||||
|
isManaging: boolean;
|
||||||
|
isBusy: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onStartManaging: (tagId: string | null) => void;
|
||||||
|
onUpdate: (input: { name?: string; color?: TagColorKey | null }) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}) {
|
||||||
|
const color = resolveTagColor(tag);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="group flex items-center gap-2 px-2 py-1.5 hover:bg-base-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex size-4 shrink-0 items-center justify-center rounded border ${
|
||||||
|
checked
|
||||||
|
? "border-primary bg-primary text-primary-content"
|
||||||
|
: "border-base-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{checked ? <Check className="size-3" /> : null}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`size-2 shrink-0 rounded-full ${tagDotClass(color)}`}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm">{tag.name}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-base-content/45">
|
||||||
|
{tag.keywordCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded p-1 text-base-content/45 hover:bg-base-300 hover:text-base-content ${
|
||||||
|
isManaging ? "bg-base-300 text-base-content" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onStartManaging(isManaging ? null : tag.id)}
|
||||||
|
aria-label={`Manage ${tag.name}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isManaging ? (
|
||||||
|
<ManageTagRow
|
||||||
|
tag={tag}
|
||||||
|
isBusy={isBusy}
|
||||||
|
onSave={onUpdate}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onCancel={() => onStartManaging(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/client/features/saved-keywords/TagChip.tsx
Normal file
66
src/client/features/saved-keywords/TagChip.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
resolveTagColor,
|
||||||
|
tagChipClass,
|
||||||
|
tagDotClass,
|
||||||
|
} from "@/shared/tag-colors";
|
||||||
|
import type { SavedKeywordTag } from "@/types/keywords";
|
||||||
|
|
||||||
|
type Size = "xs" | "sm" | "md";
|
||||||
|
|
||||||
|
const SIZE_CLASS: Record<Size, string> = {
|
||||||
|
xs: "h-5 px-1.5 text-[11px]",
|
||||||
|
sm: "h-6 px-2 text-xs",
|
||||||
|
md: "h-7 px-2.5 text-sm",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TagChip({
|
||||||
|
tag,
|
||||||
|
size = "sm",
|
||||||
|
trailing,
|
||||||
|
onClick,
|
||||||
|
selected,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
tag: Pick<SavedKeywordTag, "id" | "name" | "color">;
|
||||||
|
size?: Size;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
selected?: boolean;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
const color = resolveTagColor(tag);
|
||||||
|
const base = `inline-flex items-center gap-1.5 rounded-md font-medium ${SIZE_CLASS[size]} ${tagChipClass(color)}`;
|
||||||
|
const interactive = onClick
|
||||||
|
? "cursor-pointer hover:brightness-110 transition"
|
||||||
|
: "";
|
||||||
|
const ring = selected ? "ring-2 ring-offset-1 ring-offset-base-100" : "";
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={`size-1.5 shrink-0 rounded-full ${tagDotClass(color)}`}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{tag.name}</span>
|
||||||
|
{trailing}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
className={`${base} ${interactive} ${ring}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span title={title} className={`${base} ${ring}`}>
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
export type SavedKeywordsFilterValues = {
|
||||||
|
include: string;
|
||||||
|
exclude: string;
|
||||||
|
minVol: string;
|
||||||
|
maxVol: string;
|
||||||
|
minCpc: string;
|
||||||
|
maxCpc: string;
|
||||||
|
minKd: string;
|
||||||
|
maxKd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EMPTY_SAVED_KEYWORDS_FILTERS: SavedKeywordsFilterValues = {
|
||||||
|
include: "",
|
||||||
|
exclude: "",
|
||||||
|
minVol: "",
|
||||||
|
maxVol: "",
|
||||||
|
minCpc: "",
|
||||||
|
maxCpc: "",
|
||||||
|
minKd: "",
|
||||||
|
maxKd: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppliedSavedKeywordsFilters = {
|
||||||
|
includeTerms?: string[];
|
||||||
|
excludeTerms?: string[];
|
||||||
|
minVolume?: number | null;
|
||||||
|
maxVolume?: number | null;
|
||||||
|
minCpc?: number | null;
|
||||||
|
maxCpc?: number | null;
|
||||||
|
minDifficulty?: number | null;
|
||||||
|
maxDifficulty?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseTerms(value: string): string[] {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/[,+]/)
|
||||||
|
.map((term) => term.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, bounds: { min?: number; max?: number }) {
|
||||||
|
if (bounds.min != null && value < bounds.min) return bounds.min;
|
||||||
|
if (bounds.max != null && value > bounds.max) return bounds.max;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIntOrUndef(
|
||||||
|
value: string,
|
||||||
|
bounds: { min?: number; max?: number } = {},
|
||||||
|
): number | undefined {
|
||||||
|
if (!value.trim()) return undefined;
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return Math.trunc(clamp(n, bounds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFloatOrUndef(
|
||||||
|
value: string,
|
||||||
|
bounds: { min?: number; max?: number } = {},
|
||||||
|
): number | undefined {
|
||||||
|
if (!value.trim()) return undefined;
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return clamp(n, bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileSavedKeywordsFilters(
|
||||||
|
values: SavedKeywordsFilterValues,
|
||||||
|
): AppliedSavedKeywordsFilters {
|
||||||
|
const includeTerms = parseTerms(values.include);
|
||||||
|
const excludeTerms = parseTerms(values.exclude);
|
||||||
|
return {
|
||||||
|
includeTerms: includeTerms.length > 0 ? includeTerms : undefined,
|
||||||
|
excludeTerms: excludeTerms.length > 0 ? excludeTerms : undefined,
|
||||||
|
minVolume: toIntOrUndef(values.minVol, { min: 0 }),
|
||||||
|
maxVolume: toIntOrUndef(values.maxVol, { min: 0 }),
|
||||||
|
minCpc: toFloatOrUndef(values.minCpc, { min: 0 }),
|
||||||
|
maxCpc: toFloatOrUndef(values.maxCpc, { min: 0 }),
|
||||||
|
minDifficulty: toIntOrUndef(values.minKd, { min: 0, max: 100 }),
|
||||||
|
maxDifficulty: toIntOrUndef(values.maxKd, { min: 0, max: 100 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countActiveSavedKeywordsFilters(
|
||||||
|
values: SavedKeywordsFilterValues,
|
||||||
|
): number {
|
||||||
|
return Object.values(values).filter((value) => value.trim() !== "").length;
|
||||||
|
}
|
||||||
50
src/client/features/saved-keywords/savedKeywordsUtils.ts
Normal file
50
src/client/features/saved-keywords/savedKeywordsUtils.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import type { CsvValue } from "@/client/lib/csv";
|
||||||
|
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
||||||
|
import type { SavedKeywordRow } from "@/types/keywords";
|
||||||
|
import type { GetSavedKeywordsInput } from "@/types/schemas/keywords";
|
||||||
|
|
||||||
|
export const SAVED_KEYWORD_PAGE_SIZES = [50, 100, 250] as const;
|
||||||
|
export const SAVED_KEYWORD_EXPORT_HEADERS = [
|
||||||
|
...KEYWORD_RESEARCH_HEADERS,
|
||||||
|
"Tags",
|
||||||
|
"Fetched At",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function savedKeywordExportRow(row: SavedKeywordRow): CsvValue[] {
|
||||||
|
return [
|
||||||
|
row.keyword,
|
||||||
|
row.searchVolume ?? "",
|
||||||
|
row.cpc ?? "",
|
||||||
|
row.competition ?? "",
|
||||||
|
row.keywordDifficulty ?? "",
|
||||||
|
row.intent ?? "",
|
||||||
|
row.tags.map((tag) => tag.name).join(", "),
|
||||||
|
row.fetchedAt ?? "",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSavedKeywordSort(
|
||||||
|
value: string | undefined,
|
||||||
|
): GetSavedKeywordsInput["sort"] {
|
||||||
|
if (
|
||||||
|
value === "keyword" ||
|
||||||
|
value === "searchVolume" ||
|
||||||
|
value === "cpc" ||
|
||||||
|
value === "competition" ||
|
||||||
|
value === "keywordDifficulty" ||
|
||||||
|
value === "fetchedAt"
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "createdAt";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSavedKeywordNumber(value: number | null | undefined) {
|
||||||
|
if (value == null) return "-";
|
||||||
|
return new Intl.NumberFormat().format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSavedKeywordDate(value: string | null | undefined) {
|
||||||
|
if (!value) return "-";
|
||||||
|
return new Date(value).toLocaleDateString();
|
||||||
|
}
|
||||||
142
src/client/features/saved-keywords/useSavedKeywordsExport.ts
Normal file
142
src/client/features/saved-keywords/useSavedKeywordsExport.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import { exportSavedKeywords } from "@/serverFunctions/keywords";
|
||||||
|
import type { SavedKeywordRow } from "@/types/keywords";
|
||||||
|
import type { ExportSavedKeywordsInput } from "@/types/schemas/keywords";
|
||||||
|
import type { AppliedSavedKeywordsFilters } from "./savedKeywordsFilterTypes";
|
||||||
|
import {
|
||||||
|
SAVED_KEYWORD_EXPORT_HEADERS,
|
||||||
|
savedKeywordExportRow,
|
||||||
|
} from "./savedKeywordsUtils";
|
||||||
|
|
||||||
|
export function useSavedKeywordsExport(params: {
|
||||||
|
projectId: string;
|
||||||
|
appliedFilters: AppliedSavedKeywordsFilters;
|
||||||
|
selectedTagIds: string[];
|
||||||
|
sort: ExportSavedKeywordsInput["sort"];
|
||||||
|
order: ExportSavedKeywordsInput["order"];
|
||||||
|
}) {
|
||||||
|
const [exporting, setExporting] = useState<"csv" | "sheets" | null>(null);
|
||||||
|
const [exportingSelection, setExportingSelection] = useState<
|
||||||
|
"csv" | "sheets" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const exportInput = useMemo<ExportSavedKeywordsInput>(
|
||||||
|
() => ({
|
||||||
|
projectId: params.projectId,
|
||||||
|
...params.appliedFilters,
|
||||||
|
tagIds:
|
||||||
|
params.selectedTagIds.length > 0 ? params.selectedTagIds : undefined,
|
||||||
|
sort: params.sort,
|
||||||
|
order: params.order,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
params.appliedFilters,
|
||||||
|
params.order,
|
||||||
|
params.projectId,
|
||||||
|
params.selectedTagIds,
|
||||||
|
params.sort,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadFilteredRows = async () => {
|
||||||
|
const result = await exportSavedKeywords({ data: exportInput });
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportFilteredCsv = async () => {
|
||||||
|
setExporting("csv");
|
||||||
|
try {
|
||||||
|
const rows = await loadFilteredRows();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
toast.error("No keywords to export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadKeywordCsv(rows);
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "saved_keywords",
|
||||||
|
result_count: rows.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getStandardErrorMessage(error, "Could not export CSV"));
|
||||||
|
} finally {
|
||||||
|
setExporting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportFilteredSheets = async () => {
|
||||||
|
setExporting("sheets");
|
||||||
|
try {
|
||||||
|
const rows = await loadFilteredRows();
|
||||||
|
await exportTableToSheets({
|
||||||
|
headers: SAVED_KEYWORD_EXPORT_HEADERS,
|
||||||
|
rows: rows.map(savedKeywordExportRow),
|
||||||
|
feature: "saved_keywords",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getStandardErrorMessage(error, "Could not export to Sheets"));
|
||||||
|
} finally {
|
||||||
|
setExporting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSelectionCsv = (selectedRows: SavedKeywordRow[]) => {
|
||||||
|
if (selectedRows.length === 0) return;
|
||||||
|
setExportingSelection("csv");
|
||||||
|
try {
|
||||||
|
downloadKeywordCsv(selectedRows);
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "saved_keywords",
|
||||||
|
result_count: selectedRows.length,
|
||||||
|
scope: "selection",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setExportingSelection(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSelectionSheets = async (selectedRows: SavedKeywordRow[]) => {
|
||||||
|
if (selectedRows.length === 0) return;
|
||||||
|
setExportingSelection("sheets");
|
||||||
|
try {
|
||||||
|
await exportTableToSheets({
|
||||||
|
headers: SAVED_KEYWORD_EXPORT_HEADERS,
|
||||||
|
rows: selectedRows.map(savedKeywordExportRow),
|
||||||
|
feature: "saved_keywords",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getStandardErrorMessage(error, "Could not export to Sheets"));
|
||||||
|
} finally {
|
||||||
|
setExportingSelection(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
exporting,
|
||||||
|
exportingSelection,
|
||||||
|
exportFilteredCsv,
|
||||||
|
exportFilteredSheets,
|
||||||
|
exportSelectionCsv,
|
||||||
|
exportSelectionSheets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadKeywordCsv(rows: SavedKeywordRow[]) {
|
||||||
|
const csvRows = rows
|
||||||
|
.map(savedKeywordExportRow)
|
||||||
|
.map((row) =>
|
||||||
|
row.map((cell, index) =>
|
||||||
|
(index === 2 || index === 3) && typeof cell === "number"
|
||||||
|
? cell.toFixed(2)
|
||||||
|
: cell,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
downloadCsv(
|
||||||
|
"saved-keywords.csv",
|
||||||
|
buildCsv(SAVED_KEYWORD_EXPORT_HEADERS, csvRows),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
import { useForm, useStore } from "@tanstack/react-form";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
countActiveSavedKeywordsFilters,
|
||||||
|
EMPTY_SAVED_KEYWORDS_FILTERS,
|
||||||
|
type SavedKeywordsFilterValues,
|
||||||
|
} from "./savedKeywordsFilterTypes";
|
||||||
|
|
||||||
|
const FILTER_KEYS: Array<keyof SavedKeywordsFilterValues> = [
|
||||||
|
"include",
|
||||||
|
"exclude",
|
||||||
|
"minVol",
|
||||||
|
"maxVol",
|
||||||
|
"minCpc",
|
||||||
|
"maxCpc",
|
||||||
|
"minKd",
|
||||||
|
"maxKd",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function useSavedKeywordsFilters() {
|
||||||
|
const filtersForm = useForm({ defaultValues: EMPTY_SAVED_KEYWORDS_FILTERS });
|
||||||
|
const values = useStore(filtersForm.store, (s) => s.values);
|
||||||
|
const activeFilterCount = countActiveSavedKeywordsFilters(values);
|
||||||
|
|
||||||
|
const resetFilters = useCallback(() => {
|
||||||
|
for (const key of FILTER_KEYS) {
|
||||||
|
filtersForm.setFieldValue(key, "");
|
||||||
|
}
|
||||||
|
}, [filtersForm]);
|
||||||
|
|
||||||
|
return { filtersForm, values, activeFilterCount, resetFilters };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SavedKeywordsFilterForm = ReturnType<
|
||||||
|
typeof useSavedKeywordsFilters
|
||||||
|
>["filtersForm"];
|
||||||
72
src/client/features/saved-keywords/useTagManage.ts
Normal file
72
src/client/features/saved-keywords/useTagManage.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import {
|
||||||
|
deleteSavedKeywordTag,
|
||||||
|
updateSavedKeywordTag,
|
||||||
|
} from "@/serverFunctions/keywords";
|
||||||
|
import type { TagColorKey } from "@/shared/tag-colors";
|
||||||
|
|
||||||
|
export function useTagManage(projectId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [busyTagIds, setBusyTagIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const markBusy = (tagId: string, busy: boolean) => {
|
||||||
|
setBusyTagIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (busy) next.add(tagId);
|
||||||
|
else next.delete(tagId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const invalidate = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["savedKeywords", projectId] });
|
||||||
|
|
||||||
|
const updateTag = async (input: {
|
||||||
|
tagId: string;
|
||||||
|
name?: string;
|
||||||
|
color?: TagColorKey | null;
|
||||||
|
}) => {
|
||||||
|
markBusy(input.tagId, true);
|
||||||
|
try {
|
||||||
|
await updateSavedKeywordTag({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
tagId: input.tagId,
|
||||||
|
name: input.name,
|
||||||
|
color: input.color ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await invalidate();
|
||||||
|
toast.success("Tag updated");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getStandardErrorMessage(error, "Could not update tag"));
|
||||||
|
} finally {
|
||||||
|
markBusy(input.tagId, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTag = async (tagId: string): Promise<boolean> => {
|
||||||
|
markBusy(tagId, true);
|
||||||
|
try {
|
||||||
|
await deleteSavedKeywordTag({ data: { projectId, tagId } });
|
||||||
|
await invalidate();
|
||||||
|
toast.success("Tag deleted");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
getStandardErrorMessage(
|
||||||
|
error,
|
||||||
|
"Could not delete tag. Detach it from all keywords and try again.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
markBusy(tagId, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { busyTagIds, updateTag, deleteTag };
|
||||||
|
}
|
||||||
@ -169,34 +169,141 @@ select {
|
|||||||
@apply border-error/70 bg-error/40 text-base-content/90;
|
@apply border-error/70 bg-error/40 text-base-content/90;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyword difficulty score badges */
|
/* Tag chip colors — muted background + readable text in both themes.
|
||||||
|
Text uses darker shades on light theme for contrast, lighter on dark. */
|
||||||
|
.tag-chip-slate {
|
||||||
|
background-color: color-mix(in oklab, #64748b 14%, transparent);
|
||||||
|
color: #334155;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #64748b 30%, transparent);
|
||||||
|
}
|
||||||
|
.tag-chip-rose {
|
||||||
|
background-color: color-mix(in oklab, #f43f5e 14%, transparent);
|
||||||
|
color: #9f1239;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #f43f5e 30%, transparent);
|
||||||
|
}
|
||||||
|
.tag-chip-amber {
|
||||||
|
background-color: color-mix(in oklab, #f59e0b 16%, transparent);
|
||||||
|
color: #92400e;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #f59e0b 32%, transparent);
|
||||||
|
}
|
||||||
|
.tag-chip-lime {
|
||||||
|
background-color: color-mix(in oklab, #84cc16 16%, transparent);
|
||||||
|
color: #3f6212;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #84cc16 32%, transparent);
|
||||||
|
}
|
||||||
|
.tag-chip-emerald {
|
||||||
|
background-color: color-mix(in oklab, #10b981 14%, transparent);
|
||||||
|
color: #065f46;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #10b981 30%, transparent);
|
||||||
|
}
|
||||||
|
.tag-chip-sky {
|
||||||
|
background-color: color-mix(in oklab, #0ea5e9 14%, transparent);
|
||||||
|
color: #075985;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #0ea5e9 30%, transparent);
|
||||||
|
}
|
||||||
|
.tag-chip-violet {
|
||||||
|
background-color: color-mix(in oklab, #8b5cf6 16%, transparent);
|
||||||
|
color: #5b21b6;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #8b5cf6 32%, transparent);
|
||||||
|
}
|
||||||
|
.tag-chip-fuchsia {
|
||||||
|
background-color: color-mix(in oklab, #d946ef 14%, transparent);
|
||||||
|
color: #86198f;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #d946ef 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="openseo-dark"] .tag-chip-slate {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .tag-chip-rose {
|
||||||
|
color: #fda4af;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .tag-chip-amber {
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .tag-chip-lime {
|
||||||
|
color: #d9f99d;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .tag-chip-emerald {
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .tag-chip-sky {
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .tag-chip-violet {
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .tag-chip-fuchsia {
|
||||||
|
color: #f5d0fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyword difficulty score badges — muted/transparent style to match tag chips */
|
||||||
.score-badge {
|
.score-badge {
|
||||||
@apply border text-white;
|
@apply ring-1 ring-inset;
|
||||||
border-color: color-mix(in oklab, currentColor 26%, transparent);
|
}
|
||||||
|
|
||||||
|
.score-tier-na {
|
||||||
|
background-color: color-mix(in oklab, #94a3b8 12%, transparent);
|
||||||
|
color: #475569;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #94a3b8 28%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-tier-1 {
|
.score-tier-1 {
|
||||||
background-color: #22c55e;
|
background-color: color-mix(in oklab, #10b981 15%, transparent);
|
||||||
|
color: #065f46;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #10b981 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-tier-2 {
|
.score-tier-2 {
|
||||||
background-color: #6bd84d;
|
background-color: color-mix(in oklab, #84cc16 15%, transparent);
|
||||||
|
color: #3f6212;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #84cc16 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-tier-3 {
|
.score-tier-3 {
|
||||||
background-color: #eab308;
|
background-color: color-mix(in oklab, #eab308 16%, transparent);
|
||||||
|
color: #854d0e;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #eab308 32%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-tier-4 {
|
.score-tier-4 {
|
||||||
background-color: #f97316;
|
background-color: color-mix(in oklab, #f97316 16%, transparent);
|
||||||
|
color: #9a3412;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #f97316 32%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-tier-5 {
|
.score-tier-5 {
|
||||||
background-color: #ef4444;
|
background-color: color-mix(in oklab, #ef4444 15%, transparent);
|
||||||
|
color: #991b1b;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #ef4444 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-tier-6 {
|
.score-tier-6 {
|
||||||
background-color: #dc2626;
|
background-color: color-mix(in oklab, #b91c1c 18%, transparent);
|
||||||
|
color: #7f1d1d;
|
||||||
|
--tw-ring-color: color-mix(in oklab, #b91c1c 32%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-na {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-1 {
|
||||||
|
color: #6ee7b7;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-2 {
|
||||||
|
color: #bef264;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-3 {
|
||||||
|
color: #fde047;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-4 {
|
||||||
|
color: #fdba74;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-5 {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
html[data-theme="openseo-dark"] .score-tier-6 {
|
||||||
|
color: #fda4af;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] {
|
html[data-theme="openseo-dark"] {
|
||||||
@ -209,35 +316,6 @@ html[data-theme="openseo-dark"] {
|
|||||||
--trend-tooltip-shadow: oklch(0% 0 0 / 0.35);
|
--trend-tooltip-shadow: oklch(0% 0 0 / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] .score-badge {
|
|
||||||
color: oklch(96% 0.01 95);
|
|
||||||
filter: saturate(1.26) brightness(1.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] .score-tier-1 {
|
|
||||||
background-color: oklch(58% 0.12 148);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] .score-tier-2 {
|
|
||||||
background-color: oklch(60% 0.115 132);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] .score-tier-3 {
|
|
||||||
background-color: oklch(66% 0.11 92);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] .score-tier-4 {
|
|
||||||
background-color: oklch(64% 0.12 56);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] .score-tier-5 {
|
|
||||||
background-color: oklch(62% 0.12 36);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] .score-tier-6 {
|
|
||||||
background-color: oklch(58% 0.11 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="openseo-dark"] .alert-warning {
|
html[data-theme="openseo-dark"] .alert-warning {
|
||||||
@apply border-warning/60 bg-warning/20;
|
@apply border-warning/60 bg-warning/20;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,6 +61,57 @@ export const savedKeywords = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const savedKeywordTags = sqliteTable(
|
||||||
|
"saved_keyword_tags",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
normalizedName: text("normalized_name").notNull(),
|
||||||
|
// Palette key (e.g. "blue", "rose"). Null = derive a stable color from the
|
||||||
|
// tag id at render time. See src/shared/tag-colors.ts.
|
||||||
|
color: text("color"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("saved_keyword_tags_project_normalized_name_idx").on(
|
||||||
|
table.projectId,
|
||||||
|
table.normalizedName,
|
||||||
|
),
|
||||||
|
index("saved_keyword_tags_project_name_idx").on(
|
||||||
|
table.projectId,
|
||||||
|
table.name,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const savedKeywordTagAssignments = sqliteTable(
|
||||||
|
"saved_keyword_tag_assignments",
|
||||||
|
{
|
||||||
|
savedKeywordId: text("saved_keyword_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => savedKeywords.id, { onDelete: "cascade" }),
|
||||||
|
tagId: text("tag_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => savedKeywordTags.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(current_timestamp)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("saved_keyword_tag_assignments_unique_idx").on(
|
||||||
|
table.savedKeywordId,
|
||||||
|
table.tagId,
|
||||||
|
),
|
||||||
|
index("saved_keyword_tag_assignments_keyword_idx").on(table.savedKeywordId),
|
||||||
|
index("saved_keyword_tag_assignments_tag_idx").on(table.tagId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Latest cached metrics for a keyword within a project.
|
// Latest cached metrics for a keyword within a project.
|
||||||
// This is joined onto savedKeywords when rendering the saved keyword list.
|
// This is joined onto savedKeywords when rendering the saved keyword list.
|
||||||
export const keywordMetrics = sqliteTable(
|
export const keywordMetrics = sqliteTable(
|
||||||
|
|||||||
@ -1,358 +1,340 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import {
|
||||||
|
keepPreviousData,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
OnChangeFn,
|
||||||
|
RowSelectionState,
|
||||||
|
SortingState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { SavedKeywordsBulkActionBar } from "@/client/features/saved-keywords/SavedKeywordsBulkActionBar";
|
||||||
|
import { SavedKeywordsBulkTagsModal } from "@/client/features/saved-keywords/SavedKeywordsBulkTagsModal";
|
||||||
|
import { SavedKeywordsFilters } from "@/client/features/saved-keywords/SavedKeywordsFilters";
|
||||||
|
import { SavedKeywordsHeader } from "@/client/features/saved-keywords/SavedKeywordsHeader";
|
||||||
|
import {
|
||||||
|
DeleteSavedKeywordsModal,
|
||||||
|
RemoveSavedKeywordsError,
|
||||||
|
} from "@/client/features/saved-keywords/SavedKeywordsModals";
|
||||||
|
import { SavedKeywordsPagination } from "@/client/features/saved-keywords/SavedKeywordsPagination";
|
||||||
|
import { SavedKeywordsStatus } from "@/client/features/saved-keywords/SavedKeywordsStatus";
|
||||||
|
import { SavedKeywordsTable } from "@/client/features/saved-keywords/SavedKeywordsTable";
|
||||||
|
import { compileSavedKeywordsFilters } from "@/client/features/saved-keywords/savedKeywordsFilterTypes";
|
||||||
|
import {
|
||||||
|
toSavedKeywordSort,
|
||||||
|
type SAVED_KEYWORD_PAGE_SIZES,
|
||||||
|
} from "@/client/features/saved-keywords/savedKeywordsUtils";
|
||||||
|
import { useSavedKeywordsExport } from "@/client/features/saved-keywords/useSavedKeywordsExport";
|
||||||
|
import { useSavedKeywordsFilters } from "@/client/features/saved-keywords/useSavedKeywordsFilters";
|
||||||
|
import { useTagManage } from "@/client/features/saved-keywords/useTagManage";
|
||||||
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import {
|
import {
|
||||||
getSavedKeywords,
|
getSavedKeywords,
|
||||||
removeSavedKeywords,
|
removeSavedKeywords,
|
||||||
|
updateSavedKeywordTags,
|
||||||
} from "@/serverFunctions/keywords";
|
} from "@/serverFunctions/keywords";
|
||||||
import {
|
import type { SavedKeywordTag } from "@/types/keywords";
|
||||||
Download,
|
|
||||||
Search,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
Trash2,
|
|
||||||
Copy,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { ExportToSheetsButton } from "@/client/components/table/ExportToSheetsButton";
|
|
||||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
|
||||||
import { buildCsv, type CsvValue, downloadCsv } from "@/client/lib/csv";
|
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_project/p/$projectId/saved")({
|
export const Route = createFileRoute("/_project/p/$projectId/saved")({
|
||||||
component: SavedKeywordsPage,
|
component: SavedKeywordsPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
type SavedKeyword = {
|
const FILTER_DEBOUNCE_MS = 350;
|
||||||
id: string;
|
|
||||||
keyword: string;
|
|
||||||
searchVolume: number | null;
|
|
||||||
cpc: number | null;
|
|
||||||
competition: number | null;
|
|
||||||
keywordDifficulty: number | null;
|
|
||||||
intent: string | null;
|
|
||||||
fetchedAt: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function SavedKeywordsPage() {
|
function SavedKeywordsPage() {
|
||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] =
|
||||||
|
useState<(typeof SAVED_KEYWORD_PAGE_SIZES)[number]>(50);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "fetchedAt", desc: true },
|
||||||
|
]);
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
const [removeError, setRemoveError] = useState<string | null>(null);
|
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [showTagModal, setShowTagModal] = useState(false);
|
||||||
|
|
||||||
const { data: savedKeywordsData, isLoading } = useQuery({
|
const filters = useSavedKeywordsFilters();
|
||||||
queryKey: ["savedKeywords", projectId],
|
const [committedFilterValues, setCommittedFilterValues] = useState(
|
||||||
queryFn: () => getSavedKeywords({ data: { projectId } }),
|
filters.values,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setCommittedFilterValues(filters.values);
|
||||||
|
setPage(1);
|
||||||
|
}, FILTER_DEBOUNCE_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [filters.values]);
|
||||||
|
|
||||||
|
const appliedFilters = useMemo(
|
||||||
|
() => compileSavedKeywordsFilters(committedFilterValues),
|
||||||
|
[committedFilterValues],
|
||||||
|
);
|
||||||
|
const exportFilters = useMemo(
|
||||||
|
() => compileSavedKeywordsFilters(filters.values),
|
||||||
|
[filters.values],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortState = sorting[0];
|
||||||
|
const sort = toSavedKeywordSort(sortState?.id);
|
||||||
|
const order: "asc" | "desc" = sortState
|
||||||
|
? sortState.desc
|
||||||
|
? "desc"
|
||||||
|
: "asc"
|
||||||
|
: "desc";
|
||||||
|
const tagFilterKey = selectedTagIds.join("|");
|
||||||
|
const hasActiveFilters =
|
||||||
|
filters.activeFilterCount > 0 || selectedTagIds.length > 0;
|
||||||
|
|
||||||
|
const queryInput = useMemo(
|
||||||
|
() => ({
|
||||||
|
projectId,
|
||||||
|
...appliedFilters,
|
||||||
|
tagIds: selectedTagIds.length > 0 ? selectedTagIds : undefined,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
}),
|
||||||
|
[appliedFilters, order, page, pageSize, projectId, selectedTagIds, sort],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching } = useQuery({
|
||||||
|
queryKey: ["savedKeywords", projectId, queryInput],
|
||||||
|
queryFn: () => getSavedKeywords({ data: queryInput }),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
const savedKeywords: SavedKeyword[] = savedKeywordsData?.rows ?? [];
|
|
||||||
|
const savedKeywords = data?.rows ?? [];
|
||||||
|
const availableTags = data?.tags ?? [];
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||||
|
const selectedRows = savedKeywords.filter((row) => rowSelection[row.id]);
|
||||||
|
const selectedIds = selectedRows.map((row) => row.id);
|
||||||
|
const selectedCount = selectedIds.length;
|
||||||
|
|
||||||
|
const selectedRowTags = useMemo<SavedKeywordTag[]>(() => {
|
||||||
|
const map = new Map<string, SavedKeywordTag>();
|
||||||
|
for (const row of selectedRows) {
|
||||||
|
for (const tag of row.tags) {
|
||||||
|
if (!map.has(tag.id)) map.set(tag.id, tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()].toSorted((a, b) =>
|
||||||
|
a.normalizedName.localeCompare(b.normalizedName),
|
||||||
|
);
|
||||||
|
}, [selectedRows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRowSelection({});
|
||||||
|
}, [page, pageSize, appliedFilters, tagFilterKey, sort, order]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, totalPages]);
|
||||||
|
|
||||||
|
const invalidateSavedKeywords = () =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["savedKeywords", projectId] });
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (savedKeywordIds: string[]) =>
|
mutationFn: (savedKeywordIds: string[]) =>
|
||||||
removeSavedKeywords({ data: { projectId, savedKeywordIds } }),
|
removeSavedKeywords({ data: { projectId, savedKeywordIds } }),
|
||||||
});
|
onSuccess: (result) => {
|
||||||
|
setRowSelection({});
|
||||||
const handleDeleteSelected = async () => {
|
|
||||||
const ids = [...selected];
|
|
||||||
if (ids.length === 0) return;
|
|
||||||
|
|
||||||
setDeleting(true);
|
|
||||||
setRemoveError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeMutation.mutateAsync(ids);
|
|
||||||
setSelected(new Set());
|
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
|
setRemoveError(null);
|
||||||
|
void invalidateSavedKeywords();
|
||||||
captureClientEvent("saved_keywords:bulk_remove", {
|
captureClientEvent("saved_keywords:bulk_remove", {
|
||||||
count: ids.length,
|
count: result.deletedCount,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
`${ids.length} keyword${ids.length !== 1 ? "s" : ""} removed`,
|
`${result.deletedCount} keyword${result.deletedCount !== 1 ? "s" : ""} removed`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
},
|
||||||
|
onError: (error) => {
|
||||||
setRemoveError(getStandardErrorMessage(error, "Remove failed."));
|
setRemoveError(getStandardErrorMessage(error, "Remove failed."));
|
||||||
} finally {
|
},
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: ["savedKeywords", projectId],
|
|
||||||
});
|
});
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopySelected = () => {
|
const tagMutation = useMutation({
|
||||||
const keywords = savedKeywords
|
mutationFn: (input: {
|
||||||
.filter((kw) => selected.has(kw.id))
|
savedKeywordIds: string[];
|
||||||
.map((kw) => kw.keyword);
|
addTags?: string[];
|
||||||
void navigator.clipboard.writeText(keywords.join("\n"));
|
removeTagIds?: string[];
|
||||||
|
}) =>
|
||||||
|
updateSavedKeywordTags({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
savedKeywordIds: input.savedKeywordIds,
|
||||||
|
addTags: input.addTags,
|
||||||
|
removeTagIds: input.removeTagIds,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setRowSelection({});
|
||||||
|
setShowTagModal(false);
|
||||||
|
void invalidateSavedKeywords();
|
||||||
toast.success(
|
toast.success(
|
||||||
`${keywords.length} keyword${keywords.length !== 1 ? "s" : ""} copied`,
|
`Updated tags for ${result.taggedCount} keyword${result.taggedCount !== 1 ? "s" : ""}`,
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
onError: (error) => {
|
||||||
const toggleSelect = (id: string) => {
|
toast.error(getStandardErrorMessage(error, "Could not update tags"));
|
||||||
setSelected((prev) => {
|
},
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tagManage = useTagManage(projectId);
|
||||||
|
const exporter = useSavedKeywordsExport({
|
||||||
|
projectId,
|
||||||
|
appliedFilters: exportFilters,
|
||||||
|
selectedTagIds,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
||||||
|
setSorting((current) =>
|
||||||
|
typeof updater === "function" ? updater(current) : updater,
|
||||||
|
);
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAll = () => {
|
const handleDeleteTag = async (tagId: string) => {
|
||||||
if (selected.size === savedKeywords.length) {
|
const ok = await tagManage.deleteTag(tagId);
|
||||||
setSelected(new Set());
|
if (ok) {
|
||||||
} else {
|
setSelectedTagIds((current) => current.filter((id) => id !== tagId));
|
||||||
setSelected(new Set(savedKeywords.map((kw) => kw.id)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const savedHeaders = [...KEYWORD_RESEARCH_HEADERS, "Fetched At"];
|
const handleClearAllFilters = () => {
|
||||||
const sheetsExportRows: CsvValue[][] = savedKeywords.map((kw) => [
|
filters.resetFilters();
|
||||||
kw.keyword,
|
setSelectedTagIds([]);
|
||||||
kw.searchVolume ?? "",
|
setPage(1);
|
||||||
kw.cpc ?? "",
|
|
||||||
kw.competition ?? "",
|
|
||||||
kw.keywordDifficulty ?? "",
|
|
||||||
kw.intent ?? "",
|
|
||||||
kw.fetchedAt ?? "",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const exportCsv = () => {
|
|
||||||
if (sheetsExportRows.length === 0) {
|
|
||||||
toast.error("No keywords to export");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// CSV file keeps cents-formatted CPC/competition for human readability.
|
|
||||||
const csvRows = sheetsExportRows.map((row) =>
|
|
||||||
row.map((cell, idx) =>
|
|
||||||
(idx === 2 || idx === 3) && typeof cell === "number"
|
|
||||||
? cell.toFixed(2)
|
|
||||||
: cell,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
downloadCsv("saved-keywords.csv", buildCsv(savedHeaders, csvRows));
|
|
||||||
captureClientEvent("data:export", {
|
|
||||||
source_feature: "saved_keywords",
|
|
||||||
result_count: sheetsExportRows.length,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
|
<div className="overflow-auto px-4 py-4 pb-24 md:px-6 md:py-6 md:pb-8">
|
||||||
<div className="mx-auto max-w-5xl space-y-4">
|
<div className="mx-auto max-w-6xl space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<SavedKeywordsHeader
|
||||||
<div>
|
totalCount={totalCount}
|
||||||
<h1 className="text-2xl font-semibold">Saved Keywords</h1>
|
exporting={exporter.exporting}
|
||||||
<p className="text-sm text-base-content/70">
|
onExportCsv={() => void exporter.exportFilteredCsv()}
|
||||||
Keywords you've saved from keyword research.
|
onExportSheets={() => void exporter.exportFilteredSheets()}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
{savedKeywords.length > 0 && (
|
<div className="overflow-hidden rounded-lg border border-base-300 bg-base-100">
|
||||||
<div className="flex items-center gap-2">
|
<SavedKeywordsFilters
|
||||||
<ExportToSheetsButton
|
filtersForm={filters.filtersForm}
|
||||||
headers={savedHeaders}
|
activeFilterCount={filters.activeFilterCount}
|
||||||
rows={sheetsExportRows}
|
showFilters={showFilters}
|
||||||
feature="saved_keywords"
|
onToggleFilters={() => setShowFilters((v) => !v)}
|
||||||
className="btn-sm"
|
onResetAllFilters={handleClearAllFilters}
|
||||||
|
availableTags={availableTags}
|
||||||
|
selectedTagIds={selectedTagIds}
|
||||||
|
busyTagIds={tagManage.busyTagIds}
|
||||||
|
onToggleTagFilter={(tagId) => {
|
||||||
|
setSelectedTagIds((current) =>
|
||||||
|
current.includes(tagId)
|
||||||
|
? current.filter((id) => id !== tagId)
|
||||||
|
: [...current, tagId],
|
||||||
|
);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onClearTagSelection={() => {
|
||||||
|
setSelectedTagIds([]);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
onUpdateTag={(input) => void tagManage.updateTag(input)}
|
||||||
|
onDeleteTag={(tagId) => void handleDeleteTag(tagId)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{removeError ? (
|
||||||
|
<RemoveSavedKeywordsError message={removeError} />
|
||||||
|
) : null}
|
||||||
|
<SavedKeywordsStatus
|
||||||
|
totalCount={totalCount}
|
||||||
|
isFetching={isFetching && !isLoading}
|
||||||
|
/>
|
||||||
|
<SavedKeywordsTable
|
||||||
|
rows={savedKeywords}
|
||||||
|
rowSelection={rowSelection}
|
||||||
|
sorting={sorting}
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasActiveFilters={hasActiveFilters}
|
||||||
|
onRowSelectionChange={setRowSelection}
|
||||||
|
onSortingChange={handleSortingChange}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-sm" onClick={exportCsv}>
|
|
||||||
<Download className="size-4" /> Export CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
<SavedKeywordsPagination
|
||||||
<div className="card bg-base-100 border border-base-300">
|
page={page}
|
||||||
<div className="card-body gap-3" aria-busy>
|
pageSize={pageSize}
|
||||||
<div className="skeleton h-4 w-48" />
|
totalCount={totalCount}
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
isLoading={isFetching}
|
||||||
<div
|
onPageChange={setPage}
|
||||||
key={index}
|
onPageSizeChange={(nextPageSize) => {
|
||||||
className="grid grid-cols-8 gap-3 items-center"
|
setPageSize(nextPageSize);
|
||||||
>
|
setPage(1);
|
||||||
<div className="skeleton h-4 col-span-2" />
|
}}
|
||||||
<div className="skeleton h-4" />
|
/>
|
||||||
<div className="skeleton h-4" />
|
|
||||||
<div className="skeleton h-4" />
|
|
||||||
<div className="skeleton h-4" />
|
|
||||||
<div className="skeleton h-4" />
|
|
||||||
<div className="skeleton h-4" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : savedKeywords.length === 0 ? (
|
|
||||||
<div className="card bg-base-100 border border-base-300">
|
|
||||||
<div className="card-body text-center py-12 text-base-content/50">
|
|
||||||
<Search className="size-8 mx-auto mb-2 opacity-40" />
|
|
||||||
<p>
|
|
||||||
No saved keywords yet. Use the Keyword Research page to find and
|
|
||||||
save keywords.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="card bg-base-100 border border-base-300">
|
|
||||||
<div className="card-body gap-3">
|
|
||||||
{removeError ? (
|
|
||||||
<div className="rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error flex items-start gap-2">
|
|
||||||
<AlertCircle className="size-4 shrink-0 mt-0.5" />
|
|
||||||
<span>{removeError}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SavedKeywordsBulkActionBar
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
exportingSelection={exporter.exportingSelection}
|
||||||
|
onCopy={() => {
|
||||||
|
void navigator.clipboard.writeText(
|
||||||
|
selectedRows.map((row) => row.keyword).join("\n"),
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
`${selectedCount} keyword${selectedCount !== 1 ? "s" : ""} copied`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onOpenTags={() => setShowTagModal(true)}
|
||||||
|
onExportCsv={() => exporter.exportSelectionCsv(selectedRows)}
|
||||||
|
onExportSheets={() =>
|
||||||
|
void exporter.exportSelectionSheets(selectedRows)
|
||||||
|
}
|
||||||
|
onDelete={() => setShowConfirm(true)}
|
||||||
|
onClear={() => setRowSelection({})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showConfirm ? (
|
||||||
|
<DeleteSavedKeywordsModal
|
||||||
|
selectedCount={selectedCount}
|
||||||
|
isPending={removeMutation.isPending}
|
||||||
|
onClose={() => setShowConfirm(false)}
|
||||||
|
onConfirm={() => removeMutation.mutate(selectedIds)}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Bulk action bar or keyword count */}
|
{showTagModal ? (
|
||||||
{selected.size > 0 ? (
|
<SavedKeywordsBulkTagsModal
|
||||||
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
|
availableTags={availableTags}
|
||||||
<span className="text-base-content/70">
|
selectedCount={selectedCount}
|
||||||
{selected.size} keyword
|
selectedRowTags={selectedRowTags}
|
||||||
{selected.size !== 1 ? "s" : ""} selected
|
isPending={tagMutation.isPending}
|
||||||
</span>
|
onClose={() => setShowTagModal(false)}
|
||||||
<button
|
onApply={({ addTags, removeTagIds }) =>
|
||||||
className="btn btn-ghost btn-xs gap-1"
|
tagMutation.mutate({
|
||||||
onClick={handleCopySelected}
|
savedKeywordIds: selectedIds,
|
||||||
>
|
addTags,
|
||||||
<Copy className="size-3" />
|
removeTagIds,
|
||||||
Copy
|
})
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-error btn-xs gap-1"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3" />
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-xs"
|
|
||||||
onClick={() => setSelected(new Set())}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-base-content/70">
|
|
||||||
{savedKeywords.length} saved keyword
|
|
||||||
{savedKeywords.length !== 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="table table-zebra table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="w-8">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-xs"
|
|
||||||
checked={
|
|
||||||
selected.size === savedKeywords.length &&
|
|
||||||
savedKeywords.length > 0
|
|
||||||
}
|
}
|
||||||
onChange={toggleAll}
|
|
||||||
/>
|
/>
|
||||||
</th>
|
) : null}
|
||||||
<th>Keyword</th>
|
|
||||||
<th>Volume</th>
|
|
||||||
<th>CPC</th>
|
|
||||||
<th>Competition</th>
|
|
||||||
<th>Difficulty</th>
|
|
||||||
<th>Intent</th>
|
|
||||||
<th>Last Fetched</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{savedKeywords.map((kw) => (
|
|
||||||
<tr key={kw.id}>
|
|
||||||
<td className="w-8">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="checkbox checkbox-xs"
|
|
||||||
checked={selected.has(kw.id)}
|
|
||||||
onChange={() => toggleSelect(kw.id)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="font-medium">{kw.keyword}</td>
|
|
||||||
<td>{formatNumber(kw.searchVolume)}</td>
|
|
||||||
<td>
|
|
||||||
{kw.cpc == null ? "-" : `$${kw.cpc.toFixed(2)}`}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{kw.competition == null
|
|
||||||
? "-"
|
|
||||||
: kw.competition.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<DifficultyBadge value={kw.keywordDifficulty} />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="badge badge-sm badge-ghost">
|
|
||||||
{kw.intent ?? "?"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="text-xs text-base-content/50">
|
|
||||||
{kw.fetchedAt
|
|
||||||
? new Date(kw.fetchedAt).toLocaleDateString()
|
|
||||||
: "-"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirm delete modal */}
|
|
||||||
{showConfirm && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
||||||
<div className="card bg-base-100 border border-base-300 w-full max-w-sm shadow-xl">
|
|
||||||
<div className="card-body gap-4">
|
|
||||||
<h3 className="text-lg font-semibold">Delete keywords?</h3>
|
|
||||||
<p className="text-sm text-base-content/70">
|
|
||||||
This will permanently delete {selected.size} saved keyword
|
|
||||||
{selected.size !== 1 ? "s" : ""}.
|
|
||||||
</p>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={() => setShowConfirm(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-error btn-sm gap-1"
|
|
||||||
onClick={() => void handleDeleteSelected()}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
{deleting && <Loader2 className="size-3 animate-spin" />}
|
|
||||||
Delete {selected.size} keyword
|
|
||||||
{selected.size !== 1 ? "s" : ""}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DifficultyBadge({ value }: { value: number | null }) {
|
|
||||||
if (value == null)
|
|
||||||
return <span className="badge badge-ghost badge-sm">-</span>;
|
|
||||||
if (value < 30)
|
|
||||||
return <span className="badge badge-success badge-sm">{value}</span>;
|
|
||||||
if (value <= 60)
|
|
||||||
return <span className="badge badge-warning badge-sm">{value}</span>;
|
|
||||||
return <span className="badge badge-error badge-sm">{value}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value: number | null | undefined) {
|
|
||||||
if (value == null) return "-";
|
|
||||||
return new Intl.NumberFormat().format(value);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,61 @@
|
|||||||
import { and, count, desc, eq, inArray } from "drizzle-orm";
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
inArray,
|
||||||
|
lte,
|
||||||
|
sql,
|
||||||
|
type SQL,
|
||||||
|
} from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { keywordMetrics, savedKeywords } from "@/db/schema";
|
import {
|
||||||
|
keywordMetrics,
|
||||||
|
savedKeywordTagAssignments,
|
||||||
|
savedKeywords,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import {
|
||||||
|
SavedKeywordTagsRepository,
|
||||||
|
type SavedKeywordTagRecord,
|
||||||
|
} from "./SavedKeywordTagsRepository";
|
||||||
|
|
||||||
|
type SavedKeywordRecord = typeof savedKeywords.$inferSelect;
|
||||||
|
type KeywordMetricRecord = typeof keywordMetrics.$inferSelect;
|
||||||
|
type SavedKeywordsListParams = {
|
||||||
|
projectId: string;
|
||||||
|
search?: string;
|
||||||
|
includeTerms?: string[];
|
||||||
|
excludeTerms?: string[];
|
||||||
|
minVolume?: number | null;
|
||||||
|
maxVolume?: number | null;
|
||||||
|
minCpc?: number | null;
|
||||||
|
maxCpc?: number | null;
|
||||||
|
minDifficulty?: number | null;
|
||||||
|
maxDifficulty?: number | null;
|
||||||
|
tagIds?: string[];
|
||||||
|
tagNames?: string[];
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: SavedKeywordSortField;
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
};
|
||||||
|
|
||||||
|
type SavedKeywordSortField =
|
||||||
|
| "createdAt"
|
||||||
|
| "keyword"
|
||||||
|
| "searchVolume"
|
||||||
|
| "cpc"
|
||||||
|
| "competition"
|
||||||
|
| "keywordDifficulty"
|
||||||
|
| "fetchedAt";
|
||||||
|
|
||||||
|
type SavedKeywordListRow = {
|
||||||
|
row: SavedKeywordRecord;
|
||||||
|
metric: KeywordMetricRecord | null;
|
||||||
|
tags: SavedKeywordTagRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
async function upsertKeywordMetric(params: {
|
async function upsertKeywordMetric(params: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@ -63,8 +118,8 @@ async function saveKeywordsToProject(params: {
|
|||||||
keywords: string[];
|
keywords: string[];
|
||||||
locationCode: number;
|
locationCode: number;
|
||||||
languageCode: string;
|
languageCode: string;
|
||||||
}) {
|
}): Promise<SavedKeywordRecord[]> {
|
||||||
if (params.keywords.length === 0) return;
|
if (params.keywords.length === 0) return [];
|
||||||
|
|
||||||
const [first, ...rest] = params.keywords.map((keyword) =>
|
const [first, ...rest] = params.keywords.map((keyword) =>
|
||||||
db
|
db
|
||||||
@ -80,27 +135,208 @@ async function saveKeywordsToProject(params: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await db.batch([first, ...rest]);
|
await db.batch([first, ...rest]);
|
||||||
|
|
||||||
|
return listSavedKeywordRowsByKeywords(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listSavedKeywordsByProject(projectId: string) {
|
function escapeLike(value: string) {
|
||||||
return db
|
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
|
||||||
.select({ row: savedKeywords, metric: keywordMetrics })
|
}
|
||||||
.from(savedKeywords)
|
|
||||||
.leftJoin(
|
function buildSavedKeywordWhere(params: {
|
||||||
keywordMetrics,
|
projectId: string;
|
||||||
and(
|
search?: string;
|
||||||
|
includeTerms?: string[];
|
||||||
|
excludeTerms?: string[];
|
||||||
|
minVolume?: number | null;
|
||||||
|
maxVolume?: number | null;
|
||||||
|
minCpc?: number | null;
|
||||||
|
maxCpc?: number | null;
|
||||||
|
minDifficulty?: number | null;
|
||||||
|
maxDifficulty?: number | null;
|
||||||
|
tagIds?: string[];
|
||||||
|
}) {
|
||||||
|
const clauses: SQL[] = [eq(savedKeywords.projectId, params.projectId)];
|
||||||
|
const search = params.search?.trim();
|
||||||
|
if (search) {
|
||||||
|
clauses.push(
|
||||||
|
sql`lower(${savedKeywords.keyword}) like ${`%${escapeLike(search.toLocaleLowerCase())}%`} escape '\\'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const term of params.includeTerms ?? []) {
|
||||||
|
const trimmed = term.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
clauses.push(
|
||||||
|
sql`lower(${savedKeywords.keyword}) like ${`%${escapeLike(trimmed.toLocaleLowerCase())}%`} escape '\\'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const term of params.excludeTerms ?? []) {
|
||||||
|
const trimmed = term.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
clauses.push(
|
||||||
|
sql`lower(${savedKeywords.keyword}) not like ${`%${escapeLike(trimmed.toLocaleLowerCase())}%`} escape '\\'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.minVolume != null) {
|
||||||
|
clauses.push(gte(keywordMetrics.searchVolume, params.minVolume));
|
||||||
|
}
|
||||||
|
if (params.maxVolume != null) {
|
||||||
|
clauses.push(lte(keywordMetrics.searchVolume, params.maxVolume));
|
||||||
|
}
|
||||||
|
if (params.minCpc != null) {
|
||||||
|
clauses.push(gte(keywordMetrics.cpc, params.minCpc));
|
||||||
|
}
|
||||||
|
if (params.maxCpc != null) {
|
||||||
|
clauses.push(lte(keywordMetrics.cpc, params.maxCpc));
|
||||||
|
}
|
||||||
|
if (params.minDifficulty != null) {
|
||||||
|
clauses.push(gte(keywordMetrics.keywordDifficulty, params.minDifficulty));
|
||||||
|
}
|
||||||
|
if (params.maxDifficulty != null) {
|
||||||
|
clauses.push(lte(keywordMetrics.keywordDifficulty, params.maxDifficulty));
|
||||||
|
}
|
||||||
|
if (params.tagIds && params.tagIds.length > 0) {
|
||||||
|
clauses.push(
|
||||||
|
sql`exists (
|
||||||
|
select 1
|
||||||
|
from ${savedKeywordTagAssignments}
|
||||||
|
where ${savedKeywordTagAssignments.savedKeywordId} = ${savedKeywords.id}
|
||||||
|
and ${inArray(savedKeywordTagAssignments.tagId, params.tagIds)}
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return and(...clauses);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSavedKeywordOrderBy(
|
||||||
|
sort: SavedKeywordSortField = "createdAt",
|
||||||
|
order: "asc" | "desc" = "desc",
|
||||||
|
) {
|
||||||
|
const direction = order === "asc" ? asc : desc;
|
||||||
|
switch (sort) {
|
||||||
|
case "keyword":
|
||||||
|
return direction(savedKeywords.keyword);
|
||||||
|
case "searchVolume":
|
||||||
|
return direction(keywordMetrics.searchVolume);
|
||||||
|
case "cpc":
|
||||||
|
return direction(keywordMetrics.cpc);
|
||||||
|
case "competition":
|
||||||
|
return direction(keywordMetrics.competition);
|
||||||
|
case "keywordDifficulty":
|
||||||
|
return direction(keywordMetrics.keywordDifficulty);
|
||||||
|
case "fetchedAt":
|
||||||
|
return direction(keywordMetrics.fetchedAt);
|
||||||
|
case "createdAt":
|
||||||
|
default:
|
||||||
|
return direction(savedKeywords.createdAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSavedKeywordsByProject(
|
||||||
|
params: SavedKeywordsListParams,
|
||||||
|
): Promise<{
|
||||||
|
rows: SavedKeywordListRow[];
|
||||||
|
totalCount: number;
|
||||||
|
tags: (SavedKeywordTagRecord & { keywordCount: number })[];
|
||||||
|
}> {
|
||||||
|
const [{ tagIds, emptyTagNameMatch }, tags] = await Promise.all([
|
||||||
|
SavedKeywordTagsRepository.getTagFilterIds(params),
|
||||||
|
SavedKeywordTagsRepository.listSavedKeywordTagsByProject(params.projectId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (emptyTagNameMatch) {
|
||||||
|
return { rows: [], totalCount: 0, tags };
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = buildSavedKeywordWhere({
|
||||||
|
projectId: params.projectId,
|
||||||
|
search: params.search,
|
||||||
|
includeTerms: params.includeTerms,
|
||||||
|
excludeTerms: params.excludeTerms,
|
||||||
|
minVolume: params.minVolume,
|
||||||
|
maxVolume: params.maxVolume,
|
||||||
|
minCpc: params.minCpc,
|
||||||
|
maxCpc: params.maxCpc,
|
||||||
|
minDifficulty: params.minDifficulty,
|
||||||
|
maxDifficulty: params.maxDifficulty,
|
||||||
|
tagIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const metricJoin = and(
|
||||||
eq(keywordMetrics.keyword, savedKeywords.keyword),
|
eq(keywordMetrics.keyword, savedKeywords.keyword),
|
||||||
eq(keywordMetrics.projectId, savedKeywords.projectId),
|
eq(keywordMetrics.projectId, savedKeywords.projectId),
|
||||||
eq(keywordMetrics.locationCode, savedKeywords.locationCode),
|
eq(keywordMetrics.locationCode, savedKeywords.locationCode),
|
||||||
eq(keywordMetrics.languageCode, savedKeywords.languageCode),
|
eq(keywordMetrics.languageCode, savedKeywords.languageCode),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [{ value: totalCount } = { value: 0 }] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(savedKeywords)
|
||||||
|
.leftJoin(keywordMetrics, metricJoin)
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
const baseQuery = db
|
||||||
|
.select({ row: savedKeywords, metric: keywordMetrics })
|
||||||
|
.from(savedKeywords)
|
||||||
|
.leftJoin(keywordMetrics, metricJoin)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(
|
||||||
|
buildSavedKeywordOrderBy(params.sort, params.order),
|
||||||
|
asc(savedKeywords.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows =
|
||||||
|
params.pageSize == null
|
||||||
|
? await baseQuery
|
||||||
|
: await baseQuery
|
||||||
|
.limit(params.pageSize)
|
||||||
|
.offset(((params.page ?? 1) - 1) * params.pageSize);
|
||||||
|
const tagsByKeywordId =
|
||||||
|
await SavedKeywordTagsRepository.listTagsBySavedKeywordIds(
|
||||||
|
params.projectId,
|
||||||
|
rows.map(({ row }) => row.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCount,
|
||||||
|
tags,
|
||||||
|
rows: rows.map(({ row, metric }) => ({
|
||||||
|
row,
|
||||||
|
metric,
|
||||||
|
tags: tagsByKeywordId.get(row.id) ?? [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSavedKeywordRowsByKeywords(params: {
|
||||||
|
projectId: string;
|
||||||
|
keywords: string[];
|
||||||
|
locationCode: number;
|
||||||
|
languageCode: string;
|
||||||
|
}) {
|
||||||
|
const rows: SavedKeywordRecord[] = [];
|
||||||
|
for (let i = 0; i < params.keywords.length; i += QUERY_CHUNK_SIZE) {
|
||||||
|
const chunk = params.keywords.slice(i, i + QUERY_CHUNK_SIZE);
|
||||||
|
rows.push(
|
||||||
|
...(await db
|
||||||
|
.select()
|
||||||
|
.from(savedKeywords)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywords.projectId, params.projectId),
|
||||||
|
eq(savedKeywords.locationCode, params.locationCode),
|
||||||
|
eq(savedKeywords.languageCode, params.languageCode),
|
||||||
|
inArray(savedKeywords.keyword, chunk),
|
||||||
),
|
),
|
||||||
)
|
)),
|
||||||
.where(eq(savedKeywords.projectId, projectId))
|
);
|
||||||
.orderBy(desc(savedKeywords.createdAt));
|
}
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
// D1 caps bound parameters at 100 per statement; leave headroom for the
|
// D1 caps bound parameters at 100 per statement; leave headroom for the
|
||||||
// projectId filter.
|
// projectId filter.
|
||||||
|
const QUERY_CHUNK_SIZE = 80;
|
||||||
const DELETE_CHUNK_SIZE = 90;
|
const DELETE_CHUNK_SIZE = 90;
|
||||||
|
|
||||||
async function removeSavedKeywords(
|
async function removeSavedKeywords(
|
||||||
@ -129,5 +365,14 @@ export const KeywordResearchRepository = {
|
|||||||
countSavedKeywords,
|
countSavedKeywords,
|
||||||
saveKeywordsToProject,
|
saveKeywordsToProject,
|
||||||
listSavedKeywordsByProject,
|
listSavedKeywordsByProject,
|
||||||
|
addTagsToSavedKeywords: SavedKeywordTagsRepository.addTagsToSavedKeywords,
|
||||||
|
replaceTagsForSavedKeywords:
|
||||||
|
SavedKeywordTagsRepository.replaceTagsForSavedKeywords,
|
||||||
|
removeTagsFromSavedKeywords:
|
||||||
|
SavedKeywordTagsRepository.removeTagsFromSavedKeywords,
|
||||||
|
removeAllTagsFromSavedKeywords:
|
||||||
|
SavedKeywordTagsRepository.removeAllTagsFromSavedKeywords,
|
||||||
|
updateSavedKeywordTag: SavedKeywordTagsRepository.updateSavedKeywordTag,
|
||||||
|
deleteSavedKeywordTag: SavedKeywordTagsRepository.deleteSavedKeywordTag,
|
||||||
removeSavedKeywords,
|
removeSavedKeywords,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -0,0 +1,413 @@
|
|||||||
|
import { and, asc, count, eq, inArray, notInArray } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
savedKeywordTagAssignments,
|
||||||
|
savedKeywordTags,
|
||||||
|
savedKeywords,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import {
|
||||||
|
normalizeSavedKeywordTag,
|
||||||
|
normalizeSavedKeywordTags,
|
||||||
|
} from "@/shared/saved-keyword-tags";
|
||||||
|
|
||||||
|
export type SavedKeywordTagRecord = typeof savedKeywordTags.$inferSelect;
|
||||||
|
|
||||||
|
const QUERY_CHUNK_SIZE = 80;
|
||||||
|
const DELETE_PAIR_CHUNK_SIZE = 45;
|
||||||
|
const ASSIGNMENT_INSERT_CHUNK_SIZE = 40;
|
||||||
|
const REPLACE_DELETE_KEYWORD_CHUNK_SIZE = 70;
|
||||||
|
|
||||||
|
async function getTagFilterIds(params: {
|
||||||
|
projectId: string;
|
||||||
|
tagIds?: string[];
|
||||||
|
tagNames?: string[];
|
||||||
|
}): Promise<{ tagIds: string[]; emptyTagNameMatch: boolean }> {
|
||||||
|
const directTagIds = params.tagIds ?? [];
|
||||||
|
const normalizedTags = normalizeSavedKeywordTags(params.tagNames);
|
||||||
|
if (normalizedTags.length === 0) {
|
||||||
|
return { tagIds: [...new Set(directTagIds)], emptyTagNameMatch: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: savedKeywordTags.id })
|
||||||
|
.from(savedKeywordTags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywordTags.projectId, params.projectId),
|
||||||
|
inArray(
|
||||||
|
savedKeywordTags.normalizedName,
|
||||||
|
normalizedTags.map((tag) => tag.normalizedName),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagIds: [...new Set([...directTagIds, ...rows.map((row) => row.id)])],
|
||||||
|
emptyTagNameMatch: directTagIds.length === 0 && rows.length === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSavedKeywordTagsByProject(projectId: string) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: savedKeywordTags.id,
|
||||||
|
projectId: savedKeywordTags.projectId,
|
||||||
|
name: savedKeywordTags.name,
|
||||||
|
normalizedName: savedKeywordTags.normalizedName,
|
||||||
|
color: savedKeywordTags.color,
|
||||||
|
createdAt: savedKeywordTags.createdAt,
|
||||||
|
keywordCount: count(savedKeywordTagAssignments.savedKeywordId),
|
||||||
|
})
|
||||||
|
.from(savedKeywordTags)
|
||||||
|
.leftJoin(
|
||||||
|
savedKeywordTagAssignments,
|
||||||
|
eq(savedKeywordTagAssignments.tagId, savedKeywordTags.id),
|
||||||
|
)
|
||||||
|
.where(eq(savedKeywordTags.projectId, projectId))
|
||||||
|
.groupBy(
|
||||||
|
savedKeywordTags.id,
|
||||||
|
savedKeywordTags.projectId,
|
||||||
|
savedKeywordTags.name,
|
||||||
|
savedKeywordTags.normalizedName,
|
||||||
|
savedKeywordTags.color,
|
||||||
|
savedKeywordTags.createdAt,
|
||||||
|
)
|
||||||
|
.orderBy(asc(savedKeywordTags.normalizedName));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listTagsBySavedKeywordIds(
|
||||||
|
projectId: string,
|
||||||
|
savedKeywordIds: string[],
|
||||||
|
) {
|
||||||
|
const tagsByKeywordId = new Map<string, SavedKeywordTagRecord[]>();
|
||||||
|
if (savedKeywordIds.length === 0) return tagsByKeywordId;
|
||||||
|
|
||||||
|
for (let i = 0; i < savedKeywordIds.length; i += QUERY_CHUNK_SIZE) {
|
||||||
|
const chunk = savedKeywordIds.slice(i, i + QUERY_CHUNK_SIZE);
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
savedKeywordId: savedKeywordTagAssignments.savedKeywordId,
|
||||||
|
tag: savedKeywordTags,
|
||||||
|
})
|
||||||
|
.from(savedKeywordTagAssignments)
|
||||||
|
.innerJoin(
|
||||||
|
savedKeywordTags,
|
||||||
|
eq(savedKeywordTags.id, savedKeywordTagAssignments.tagId),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywordTags.projectId, projectId),
|
||||||
|
inArray(savedKeywordTagAssignments.savedKeywordId, chunk),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(savedKeywordTags.normalizedName));
|
||||||
|
|
||||||
|
for (const { savedKeywordId, tag } of rows) {
|
||||||
|
const tags = tagsByKeywordId.get(savedKeywordId) ?? [];
|
||||||
|
tags.push(tag);
|
||||||
|
tagsByKeywordId.set(savedKeywordId, tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagsByKeywordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTagsToSavedKeywords(params: {
|
||||||
|
projectId: string;
|
||||||
|
savedKeywordIds: string[];
|
||||||
|
tagNames: string[];
|
||||||
|
}) {
|
||||||
|
const savedKeywordRows = await listSavedKeywordRowsByIds(
|
||||||
|
params.projectId,
|
||||||
|
params.savedKeywordIds,
|
||||||
|
);
|
||||||
|
if (savedKeywordRows.length === 0) {
|
||||||
|
return { savedKeywordCount: 0, tags: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await upsertSavedKeywordTags(params.projectId, params.tagNames);
|
||||||
|
|
||||||
|
const assignments = savedKeywordRows.flatMap((row) =>
|
||||||
|
tags.map((tag) => ({ savedKeywordId: row.id, tagId: tag.id })),
|
||||||
|
);
|
||||||
|
for (let i = 0; i < assignments.length; i += ASSIGNMENT_INSERT_CHUNK_SIZE) {
|
||||||
|
const chunk = assignments.slice(i, i + ASSIGNMENT_INSERT_CHUNK_SIZE);
|
||||||
|
await db
|
||||||
|
.insert(savedKeywordTagAssignments)
|
||||||
|
.values(chunk)
|
||||||
|
.onConflictDoNothing();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { savedKeywordCount: savedKeywordRows.length, tags };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceTagsForSavedKeywords(params: {
|
||||||
|
projectId: string;
|
||||||
|
savedKeywordIds: string[];
|
||||||
|
tagNames: string[];
|
||||||
|
}) {
|
||||||
|
const addResult = await addTagsToSavedKeywords(params);
|
||||||
|
const keepTagIds = addResult.tags.map((tag) => tag.id);
|
||||||
|
if (addResult.savedKeywordCount === 0 || keepTagIds.length === 0) {
|
||||||
|
return { ...addResult, removedCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedCount = 0;
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < params.savedKeywordIds.length;
|
||||||
|
i += REPLACE_DELETE_KEYWORD_CHUNK_SIZE
|
||||||
|
) {
|
||||||
|
const chunk = params.savedKeywordIds.slice(
|
||||||
|
i,
|
||||||
|
i + REPLACE_DELETE_KEYWORD_CHUNK_SIZE,
|
||||||
|
);
|
||||||
|
const savedKeywordRows = await listSavedKeywordRowsByIds(
|
||||||
|
params.projectId,
|
||||||
|
chunk,
|
||||||
|
);
|
||||||
|
const savedKeywordIds = savedKeywordRows.map((row) => row.id);
|
||||||
|
if (savedKeywordIds.length === 0) continue;
|
||||||
|
|
||||||
|
const deleted = await db
|
||||||
|
.delete(savedKeywordTagAssignments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(savedKeywordTagAssignments.savedKeywordId, savedKeywordIds),
|
||||||
|
notInArray(savedKeywordTagAssignments.tagId, keepTagIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: savedKeywordTagAssignments.savedKeywordId });
|
||||||
|
removedCount += deleted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...addResult, removedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTagsFromSavedKeywords(params: {
|
||||||
|
projectId: string;
|
||||||
|
savedKeywordIds: string[];
|
||||||
|
tagIds: string[];
|
||||||
|
}) {
|
||||||
|
const [savedKeywordRows, tags] = await Promise.all([
|
||||||
|
listSavedKeywordRowsByIds(params.projectId, params.savedKeywordIds),
|
||||||
|
listSavedKeywordTagsByIds(params.projectId, params.tagIds),
|
||||||
|
]);
|
||||||
|
const savedKeywordIds = savedKeywordRows.map((row) => row.id);
|
||||||
|
const tagIds = tags.map((tag) => tag.id);
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < savedKeywordIds.length; i += DELETE_PAIR_CHUNK_SIZE) {
|
||||||
|
const savedKeywordChunk = savedKeywordIds.slice(
|
||||||
|
i,
|
||||||
|
i + DELETE_PAIR_CHUNK_SIZE,
|
||||||
|
);
|
||||||
|
for (let j = 0; j < tagIds.length; j += DELETE_PAIR_CHUNK_SIZE) {
|
||||||
|
const tagChunk = tagIds.slice(j, j + DELETE_PAIR_CHUNK_SIZE);
|
||||||
|
const deleted = await db
|
||||||
|
.delete(savedKeywordTagAssignments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(
|
||||||
|
savedKeywordTagAssignments.savedKeywordId,
|
||||||
|
savedKeywordChunk,
|
||||||
|
),
|
||||||
|
inArray(savedKeywordTagAssignments.tagId, tagChunk),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ tagId: savedKeywordTagAssignments.tagId });
|
||||||
|
removedCount += deleted.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { removedCount, savedKeywordCount: savedKeywordRows.length, tags };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAllTagsFromSavedKeywords(params: {
|
||||||
|
projectId: string;
|
||||||
|
savedKeywordIds: string[];
|
||||||
|
}) {
|
||||||
|
const savedKeywordRows = await listSavedKeywordRowsByIds(
|
||||||
|
params.projectId,
|
||||||
|
params.savedKeywordIds,
|
||||||
|
);
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < savedKeywordRows.length; i += QUERY_CHUNK_SIZE) {
|
||||||
|
const chunk = savedKeywordRows.slice(i, i + QUERY_CHUNK_SIZE);
|
||||||
|
const deleted = await db
|
||||||
|
.delete(savedKeywordTagAssignments)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
savedKeywordTagAssignments.savedKeywordId,
|
||||||
|
chunk.map((row) => row.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: savedKeywordTagAssignments.savedKeywordId });
|
||||||
|
removedCount += deleted.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { removedCount, savedKeywordCount: savedKeywordRows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertSavedKeywordTags(
|
||||||
|
projectId: string,
|
||||||
|
tagNames: readonly string[] | undefined,
|
||||||
|
) {
|
||||||
|
const normalizedTags = normalizeSavedKeywordTags(tagNames);
|
||||||
|
if (normalizedTags.length === 0) return [];
|
||||||
|
|
||||||
|
const [first, ...rest] = normalizedTags.map((tag) =>
|
||||||
|
db
|
||||||
|
.insert(savedKeywordTags)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
projectId,
|
||||||
|
name: tag.name,
|
||||||
|
normalizedName: tag.normalizedName,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing(),
|
||||||
|
);
|
||||||
|
await db.batch([first, ...rest]);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(savedKeywordTags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywordTags.projectId, projectId),
|
||||||
|
inArray(
|
||||||
|
savedKeywordTags.normalizedName,
|
||||||
|
normalizedTags.map((tag) => tag.normalizedName),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(savedKeywordTags.normalizedName));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSavedKeywordRowsByIds(
|
||||||
|
projectId: string,
|
||||||
|
savedKeywordIds: string[],
|
||||||
|
) {
|
||||||
|
const rows: (typeof savedKeywords.$inferSelect)[] = [];
|
||||||
|
for (let i = 0; i < savedKeywordIds.length; i += QUERY_CHUNK_SIZE) {
|
||||||
|
const chunk = savedKeywordIds.slice(i, i + QUERY_CHUNK_SIZE);
|
||||||
|
rows.push(
|
||||||
|
...(await db
|
||||||
|
.select()
|
||||||
|
.from(savedKeywords)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywords.projectId, projectId),
|
||||||
|
inArray(savedKeywords.id, chunk),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSavedKeywordTagsByIds(projectId: string, tagIds: string[]) {
|
||||||
|
if (tagIds.length === 0) return [];
|
||||||
|
const rows: SavedKeywordTagRecord[] = [];
|
||||||
|
for (let i = 0; i < tagIds.length; i += QUERY_CHUNK_SIZE) {
|
||||||
|
const chunk = tagIds.slice(i, i + QUERY_CHUNK_SIZE);
|
||||||
|
rows.push(
|
||||||
|
...(await db
|
||||||
|
.select()
|
||||||
|
.from(savedKeywordTags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywordTags.projectId, projectId),
|
||||||
|
inArray(savedKeywordTags.id, chunk),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSavedKeywordTag(params: {
|
||||||
|
projectId: string;
|
||||||
|
tagId: string;
|
||||||
|
name?: string;
|
||||||
|
color?: string | null;
|
||||||
|
}) {
|
||||||
|
const updates: Partial<typeof savedKeywordTags.$inferInsert> = {};
|
||||||
|
if (params.name !== undefined) {
|
||||||
|
const normalizedTag = normalizeSavedKeywordTag(params.name);
|
||||||
|
if (!normalizedTag) return null;
|
||||||
|
updates.name = normalizedTag.name;
|
||||||
|
updates.normalizedName = normalizedTag.normalizedName;
|
||||||
|
}
|
||||||
|
if (params.color !== undefined) {
|
||||||
|
updates.color = params.color;
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length === 0) return null;
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(savedKeywordTags)
|
||||||
|
.set(updates)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywordTags.projectId, params.projectId),
|
||||||
|
eq(savedKeywordTags.id, params.tagId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
return updated ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSavedKeywordTag(params: {
|
||||||
|
projectId: string;
|
||||||
|
tagId: string;
|
||||||
|
}): Promise<
|
||||||
|
| { status: "deleted" }
|
||||||
|
| { status: "not_found" }
|
||||||
|
| { status: "in_use"; assignmentCount: number }
|
||||||
|
> {
|
||||||
|
const [tag] = await db
|
||||||
|
.select({ id: savedKeywordTags.id })
|
||||||
|
.from(savedKeywordTags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywordTags.projectId, params.projectId),
|
||||||
|
eq(savedKeywordTags.id, params.tagId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!tag) return { status: "not_found" };
|
||||||
|
|
||||||
|
// Guard: refuse to delete a tag that's still attached to saved keywords.
|
||||||
|
// FK cascade would otherwise silently drop assignments.
|
||||||
|
const [{ value: assignmentCount } = { value: 0 }] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(savedKeywordTagAssignments)
|
||||||
|
.where(eq(savedKeywordTagAssignments.tagId, params.tagId));
|
||||||
|
|
||||||
|
if (assignmentCount > 0) {
|
||||||
|
return { status: "in_use", assignmentCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await db
|
||||||
|
.delete(savedKeywordTags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedKeywordTags.projectId, params.projectId),
|
||||||
|
eq(savedKeywordTags.id, params.tagId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: savedKeywordTags.id });
|
||||||
|
return deleted.length > 0 ? { status: "deleted" } : { status: "not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SavedKeywordTagsRepository = {
|
||||||
|
getTagFilterIds,
|
||||||
|
listSavedKeywordTagsByProject,
|
||||||
|
listTagsBySavedKeywordIds,
|
||||||
|
addTagsToSavedKeywords,
|
||||||
|
replaceTagsForSavedKeywords,
|
||||||
|
removeTagsFromSavedKeywords,
|
||||||
|
removeAllTagsFromSavedKeywords,
|
||||||
|
updateSavedKeywordTag,
|
||||||
|
deleteSavedKeywordTag,
|
||||||
|
} as const;
|
||||||
@ -1,9 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
deleteSavedKeywordTag,
|
||||||
getSavedKeywords,
|
getSavedKeywords,
|
||||||
getSerpAnalysis,
|
getSerpAnalysis,
|
||||||
removeSavedKeywords,
|
removeSavedKeywords,
|
||||||
research,
|
research,
|
||||||
saveKeywords,
|
saveKeywords,
|
||||||
|
exportSavedKeywords,
|
||||||
|
updateSavedKeywordTag,
|
||||||
|
updateSavedKeywordTags,
|
||||||
} from "@/server/features/keywords/services/research";
|
} from "@/server/features/keywords/services/research";
|
||||||
|
|
||||||
export const KeywordResearchService = {
|
export const KeywordResearchService = {
|
||||||
@ -11,5 +15,9 @@ export const KeywordResearchService = {
|
|||||||
getSerpAnalysis,
|
getSerpAnalysis,
|
||||||
saveKeywords,
|
saveKeywords,
|
||||||
getSavedKeywords,
|
getSavedKeywords,
|
||||||
|
exportSavedKeywords,
|
||||||
|
updateSavedKeywordTags,
|
||||||
|
updateSavedKeywordTag,
|
||||||
|
deleteSavedKeywordTag,
|
||||||
removeSavedKeywords,
|
removeSavedKeywords,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -3,5 +3,9 @@ export { getSerpAnalysis } from "./serp";
|
|||||||
export {
|
export {
|
||||||
saveKeywords,
|
saveKeywords,
|
||||||
getSavedKeywords,
|
getSavedKeywords,
|
||||||
|
exportSavedKeywords,
|
||||||
|
updateSavedKeywordTags,
|
||||||
|
updateSavedKeywordTag,
|
||||||
|
deleteSavedKeywordTag,
|
||||||
removeSavedKeywords,
|
removeSavedKeywords,
|
||||||
} from "./saved-keywords";
|
} from "./saved-keywords";
|
||||||
|
|||||||
@ -0,0 +1,248 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
addTagsToSavedKeywords: vi.fn(),
|
||||||
|
listSavedKeywordsByProject: vi.fn(),
|
||||||
|
removeAllTagsFromSavedKeywords: vi.fn(),
|
||||||
|
removeSavedKeywords: vi.fn(),
|
||||||
|
removeTagsFromSavedKeywords: vi.fn(),
|
||||||
|
replaceTagsForSavedKeywords: vi.fn(),
|
||||||
|
saveKeywordsToProject: vi.fn(),
|
||||||
|
upsertKeywordMetric: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
"@/server/features/keywords/repositories/KeywordResearchRepository",
|
||||||
|
() => ({
|
||||||
|
KeywordResearchRepository: mocks,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const savedKeywordRow = {
|
||||||
|
id: "saved_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
keyword: "technical seo",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
createdAt: "2026-05-11T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("saved keyword service", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches tags to saved keyword rows after saving", async () => {
|
||||||
|
mocks.saveKeywordsToProject.mockResolvedValue([
|
||||||
|
savedKeywordRow,
|
||||||
|
{ ...savedKeywordRow, id: "saved_2", keyword: "content seo" },
|
||||||
|
]);
|
||||||
|
mocks.addTagsToSavedKeywords.mockResolvedValue({
|
||||||
|
savedKeywordCount: 2,
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
const { saveKeywords } = await import("./saved-keywords");
|
||||||
|
|
||||||
|
await saveKeywords({
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: [" Technical SEO ", "technical seo", "Content SEO"],
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
tagMode: "append",
|
||||||
|
tags: ["Content", "BOFU"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.saveKeywordsToProject).toHaveBeenCalledWith({
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["technical seo", "content seo"],
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
});
|
||||||
|
expect(mocks.addTagsToSavedKeywords).toHaveBeenCalledWith({
|
||||||
|
projectId: "project_1",
|
||||||
|
savedKeywordIds: ["saved_1", "saved_2"],
|
||||||
|
tagNames: ["Content", "BOFU"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call tag assignment when no tags are provided", async () => {
|
||||||
|
mocks.saveKeywordsToProject.mockResolvedValue([savedKeywordRow]);
|
||||||
|
const { saveKeywords } = await import("./saved-keywords");
|
||||||
|
|
||||||
|
await saveKeywords({
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["technical seo"],
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
tagMode: "append",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.addTagsToSavedKeywords).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps paged saved keyword rows with attached tags", async () => {
|
||||||
|
mocks.listSavedKeywordsByProject.mockResolvedValue({
|
||||||
|
totalCount: 1,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
id: "tag_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
name: "Content",
|
||||||
|
normalizedName: "content",
|
||||||
|
color: null,
|
||||||
|
createdAt: "2026-05-11T00:00:00.000Z",
|
||||||
|
keywordCount: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
row: savedKeywordRow,
|
||||||
|
metric: {
|
||||||
|
id: 1,
|
||||||
|
projectId: "project_1",
|
||||||
|
keyword: "technical seo",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
searchVolume: 120,
|
||||||
|
cpc: 2.5,
|
||||||
|
competition: 0.2,
|
||||||
|
keywordDifficulty: 18,
|
||||||
|
intent: "informational",
|
||||||
|
monthlySearches: null,
|
||||||
|
fetchedAt: "2026-05-10T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
id: "tag_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
name: "Content",
|
||||||
|
normalizedName: "content",
|
||||||
|
color: null,
|
||||||
|
createdAt: "2026-05-11T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { getSavedKeywords } = await import("./saved-keywords");
|
||||||
|
|
||||||
|
const result = await getSavedKeywords({
|
||||||
|
projectId: "project_1",
|
||||||
|
tagIds: ["tag_1"],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
sort: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.listSavedKeywordsByProject).toHaveBeenCalledWith({
|
||||||
|
projectId: "project_1",
|
||||||
|
search: undefined,
|
||||||
|
tagIds: ["tag_1"],
|
||||||
|
tagNames: undefined,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
sort: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
});
|
||||||
|
expect(result.rows[0]?.tags).toEqual([
|
||||||
|
{ id: "tag_1", name: "Content", normalizedName: "content", color: null },
|
||||||
|
]);
|
||||||
|
expect(result.tags).toEqual([
|
||||||
|
{
|
||||||
|
id: "tag_1",
|
||||||
|
name: "Content",
|
||||||
|
normalizedName: "content",
|
||||||
|
color: null,
|
||||||
|
keywordCount: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates saved keyword tags through add and remove operations", async () => {
|
||||||
|
mocks.addTagsToSavedKeywords.mockResolvedValue({
|
||||||
|
savedKeywordCount: 2,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
id: "tag_1",
|
||||||
|
name: "Content",
|
||||||
|
normalizedName: "content",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
mocks.removeTagsFromSavedKeywords.mockResolvedValue({
|
||||||
|
savedKeywordCount: 2,
|
||||||
|
removedCount: 2,
|
||||||
|
tags: [{ id: "tag_2" }],
|
||||||
|
});
|
||||||
|
const { updateSavedKeywordTags } = await import("./saved-keywords");
|
||||||
|
|
||||||
|
const result = await updateSavedKeywordTags({
|
||||||
|
projectId: "project_1",
|
||||||
|
savedKeywordIds: ["saved_1", "saved_2"],
|
||||||
|
addTags: ["Content"],
|
||||||
|
removeTagIds: ["tag_2"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
taggedCount: 2,
|
||||||
|
addedTags: [
|
||||||
|
{
|
||||||
|
id: "tag_1",
|
||||||
|
name: "Content",
|
||||||
|
normalizedName: "content",
|
||||||
|
color: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removedTagIds: ["tag_2"],
|
||||||
|
removedAssignments: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces tags only for the exact saved keyword rows returned by save", async () => {
|
||||||
|
mocks.saveKeywordsToProject.mockResolvedValue([
|
||||||
|
{ ...savedKeywordRow, id: "saved_us", keyword: "technical seo" },
|
||||||
|
]);
|
||||||
|
mocks.replaceTagsForSavedKeywords.mockResolvedValue({
|
||||||
|
savedKeywordCount: 1,
|
||||||
|
removedCount: 1,
|
||||||
|
tags: [{ id: "tag_new", name: "US", normalizedName: "us" }],
|
||||||
|
});
|
||||||
|
const { saveKeywords } = await import("./saved-keywords");
|
||||||
|
|
||||||
|
const result = await saveKeywords({
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["technical seo"],
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
tags: ["US"],
|
||||||
|
tagMode: "replace",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.replaceTagsForSavedKeywords).toHaveBeenCalledWith({
|
||||||
|
projectId: "project_1",
|
||||||
|
savedKeywordIds: ["saved_us"],
|
||||||
|
tagNames: ["US"],
|
||||||
|
});
|
||||||
|
expect(mocks.addTagsToSavedKeywords).not.toHaveBeenCalled();
|
||||||
|
expect(result.savedKeywordIds).toEqual(["saved_us"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects replace mode without replacement tags", async () => {
|
||||||
|
mocks.saveKeywordsToProject.mockResolvedValue([savedKeywordRow]);
|
||||||
|
const { saveKeywords } = await import("./saved-keywords");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
saveKeywords({
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["technical seo"],
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
tagMode: "replace",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Replacement tags are required");
|
||||||
|
expect(mocks.replaceTagsForSavedKeywords).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,11 +1,19 @@
|
|||||||
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
|
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
|
||||||
import { jsonCodec } from "@/shared/json";
|
import { jsonCodec } from "@/shared/json";
|
||||||
import type {
|
import type {
|
||||||
|
DeleteSavedKeywordTagInput,
|
||||||
|
ExportSavedKeywordsInput,
|
||||||
GetSavedKeywordsInput,
|
GetSavedKeywordsInput,
|
||||||
RemoveSavedKeywordsInput,
|
RemoveSavedKeywordsInput,
|
||||||
SaveKeywordsInput,
|
SaveKeywordsInput,
|
||||||
|
UpdateSavedKeywordTagInput,
|
||||||
|
UpdateSavedKeywordTagsInput,
|
||||||
} from "@/types/schemas/keywords";
|
} from "@/types/schemas/keywords";
|
||||||
import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
|
import type {
|
||||||
|
MonthlySearch,
|
||||||
|
SavedKeywordRow,
|
||||||
|
SavedKeywordTagSummary,
|
||||||
|
} from "@/types/keywords";
|
||||||
import { normalizeKeyword } from "./helpers";
|
import { normalizeKeyword } from "./helpers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@ -69,25 +77,136 @@ export async function saveKeywords(input: SaveKeywordsInput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await KeywordResearchRepository.saveKeywordsToProject({
|
const savedRows = await KeywordResearchRepository.saveKeywordsToProject({
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
keywords: normalizedKeywords,
|
keywords: normalizedKeywords,
|
||||||
locationCode: input.locationCode,
|
locationCode: input.locationCode,
|
||||||
languageCode: input.languageCode,
|
languageCode: input.languageCode,
|
||||||
});
|
});
|
||||||
|
const savedKeywordIds = savedRows.map((row) => row.id);
|
||||||
|
|
||||||
return { success: true };
|
if (input.tagMode === "replace") {
|
||||||
|
const replacementTags = input.tags ?? [];
|
||||||
|
if (replacementTags.length === 0) {
|
||||||
|
throw new Error("Replacement tags are required when tagMode is replace.");
|
||||||
|
}
|
||||||
|
await KeywordResearchRepository.replaceTagsForSavedKeywords({
|
||||||
|
projectId: input.projectId,
|
||||||
|
savedKeywordIds,
|
||||||
|
tagNames: replacementTags,
|
||||||
|
});
|
||||||
|
} else if ((input.tags?.length ?? 0) > 0) {
|
||||||
|
await KeywordResearchRepository.addTagsToSavedKeywords({
|
||||||
|
projectId: input.projectId,
|
||||||
|
savedKeywordIds,
|
||||||
|
tagNames: input.tags ?? [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSavedKeywords(
|
return {
|
||||||
input: GetSavedKeywordsInput,
|
success: true,
|
||||||
): Promise<{ rows: SavedKeywordRow[] }> {
|
savedKeywordIds,
|
||||||
const rows = await KeywordResearchRepository.listSavedKeywordsByProject(
|
};
|
||||||
input.projectId,
|
}
|
||||||
);
|
|
||||||
|
export async function getSavedKeywords(input: GetSavedKeywordsInput): Promise<{
|
||||||
|
rows: SavedKeywordRow[];
|
||||||
|
totalCount: number;
|
||||||
|
tags: SavedKeywordTagSummary[];
|
||||||
|
}> {
|
||||||
|
const result = await KeywordResearchRepository.listSavedKeywordsByProject({
|
||||||
|
projectId: input.projectId,
|
||||||
|
search: input.search,
|
||||||
|
includeTerms: input.includeTerms,
|
||||||
|
excludeTerms: input.excludeTerms,
|
||||||
|
minVolume: input.minVolume,
|
||||||
|
maxVolume: input.maxVolume,
|
||||||
|
minCpc: input.minCpc,
|
||||||
|
maxCpc: input.maxCpc,
|
||||||
|
minDifficulty: input.minDifficulty,
|
||||||
|
maxDifficulty: input.maxDifficulty,
|
||||||
|
tagIds: input.tagIds,
|
||||||
|
tagNames: input.tagNames,
|
||||||
|
page: input.page,
|
||||||
|
pageSize: input.pageSize,
|
||||||
|
sort: input.sort,
|
||||||
|
order: input.order,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: rows.map(({ row, metric }) => ({
|
rows: mapSavedKeywordRows(result.rows),
|
||||||
|
totalCount: result.totalCount,
|
||||||
|
tags: mapSavedKeywordTags(result.tags),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSavedKeywords(
|
||||||
|
input: ExportSavedKeywordsInput,
|
||||||
|
): Promise<{ rows: SavedKeywordRow[] }> {
|
||||||
|
const result = await KeywordResearchRepository.listSavedKeywordsByProject({
|
||||||
|
projectId: input.projectId,
|
||||||
|
search: input.search,
|
||||||
|
includeTerms: input.includeTerms,
|
||||||
|
excludeTerms: input.excludeTerms,
|
||||||
|
minVolume: input.minVolume,
|
||||||
|
maxVolume: input.maxVolume,
|
||||||
|
minCpc: input.minCpc,
|
||||||
|
maxCpc: input.maxCpc,
|
||||||
|
minDifficulty: input.minDifficulty,
|
||||||
|
maxDifficulty: input.maxDifficulty,
|
||||||
|
tagIds: input.tagIds,
|
||||||
|
tagNames: input.tagNames,
|
||||||
|
sort: input.sort,
|
||||||
|
order: input.order,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { rows: mapSavedKeywordRows(result.rows) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSavedKeywordTags(
|
||||||
|
input: UpdateSavedKeywordTagsInput,
|
||||||
|
) {
|
||||||
|
const addResult =
|
||||||
|
(input.addTags?.length ?? 0) > 0
|
||||||
|
? await KeywordResearchRepository.addTagsToSavedKeywords({
|
||||||
|
projectId: input.projectId,
|
||||||
|
savedKeywordIds: input.savedKeywordIds,
|
||||||
|
tagNames: input.addTags ?? [],
|
||||||
|
})
|
||||||
|
: { savedKeywordCount: 0, tags: [] };
|
||||||
|
|
||||||
|
const removeResult =
|
||||||
|
(input.removeTagIds?.length ?? 0) > 0
|
||||||
|
? await KeywordResearchRepository.removeTagsFromSavedKeywords({
|
||||||
|
projectId: input.projectId,
|
||||||
|
savedKeywordIds: input.savedKeywordIds,
|
||||||
|
tagIds: input.removeTagIds ?? [],
|
||||||
|
})
|
||||||
|
: { removedCount: 0, savedKeywordCount: 0, tags: [] };
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
taggedCount: Math.max(
|
||||||
|
addResult.savedKeywordCount,
|
||||||
|
removeResult.savedKeywordCount,
|
||||||
|
),
|
||||||
|
addedTags: addResult.tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
normalizedName: tag.normalizedName,
|
||||||
|
color: tag.color ?? null,
|
||||||
|
})),
|
||||||
|
removedTagIds: removeResult.tags.map((tag) => tag.id),
|
||||||
|
removedAssignments: removeResult.removedCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSavedKeywordRows(
|
||||||
|
rows: Awaited<
|
||||||
|
ReturnType<typeof KeywordResearchRepository.listSavedKeywordsByProject>
|
||||||
|
>["rows"],
|
||||||
|
): SavedKeywordRow[] {
|
||||||
|
return rows.map(({ row, metric, tags }) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
projectId: row.projectId,
|
projectId: row.projectId,
|
||||||
keyword: row.keyword,
|
keyword: row.keyword,
|
||||||
@ -101,10 +220,69 @@ export async function getSavedKeywords(
|
|||||||
intent: metric?.intent ?? null,
|
intent: metric?.intent ?? null,
|
||||||
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null),
|
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? null),
|
||||||
fetchedAt: metric?.fetchedAt ?? null,
|
fetchedAt: metric?.fetchedAt ?? null,
|
||||||
|
tags: tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
normalizedName: tag.normalizedName,
|
||||||
|
color: tag.color ?? null,
|
||||||
})),
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSavedKeywordTags(
|
||||||
|
tags: Awaited<
|
||||||
|
ReturnType<typeof KeywordResearchRepository.listSavedKeywordsByProject>
|
||||||
|
>["tags"],
|
||||||
|
): SavedKeywordTagSummary[] {
|
||||||
|
return tags.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
name: tag.name,
|
||||||
|
normalizedName: tag.normalizedName,
|
||||||
|
color: tag.color ?? null,
|
||||||
|
keywordCount: tag.keywordCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSavedKeywordTag(input: UpdateSavedKeywordTagInput) {
|
||||||
|
const updated = await KeywordResearchRepository.updateSavedKeywordTag({
|
||||||
|
projectId: input.projectId,
|
||||||
|
tagId: input.tagId,
|
||||||
|
name: input.name,
|
||||||
|
color: input.color,
|
||||||
|
});
|
||||||
|
if (!updated) return { success: false as const };
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
tag: {
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
normalizedName: updated.normalizedName,
|
||||||
|
color: updated.color ?? null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteSavedKeywordTag(input: DeleteSavedKeywordTagInput) {
|
||||||
|
const result = await KeywordResearchRepository.deleteSavedKeywordTag({
|
||||||
|
projectId: input.projectId,
|
||||||
|
tagId: input.tagId,
|
||||||
|
});
|
||||||
|
if (result.status === "in_use") {
|
||||||
|
throw new TagInUseError(result.assignmentCount);
|
||||||
|
}
|
||||||
|
return { success: result.status === "deleted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
class TagInUseError extends Error {
|
||||||
|
readonly code = "TAG_IN_USE" as const;
|
||||||
|
constructor(readonly assignmentCount: number) {
|
||||||
|
super(
|
||||||
|
`Tag is attached to ${assignmentCount} keyword${assignmentCount === 1 ? "" : "s"}. Remove the tag from those keywords first.`,
|
||||||
|
);
|
||||||
|
this.name = "TagInUseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeSavedKeywords(
|
export async function removeSavedKeywords(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
input: RemoveSavedKeywordsInput,
|
input: RemoveSavedKeywordsInput,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { z } from "zod";
|
import { z } from "zod";
|
||||||
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
|
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
|
||||||
import { mcpResponse } from "@/server/mcp/formatters";
|
import { mcpResponse } from "@/server/mcp/formatters";
|
||||||
import { buildProjectMeta } from "@/server/mcp/context";
|
import { buildProjectMeta } from "@/server/mcp/context";
|
||||||
@ -7,6 +7,21 @@ import { projectIdSchema } from "@/server/mcp/schemas";
|
|||||||
|
|
||||||
const inputSchema = {
|
const inputSchema = {
|
||||||
projectId: projectIdSchema,
|
projectId: projectIdSchema,
|
||||||
|
search: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.describe("Optional keyword text filter."),
|
||||||
|
tags: z
|
||||||
|
.array(z.string().min(1).max(64))
|
||||||
|
.max(20)
|
||||||
|
.optional()
|
||||||
|
.describe("Optional tag-name filters. Multiple tags match ANY tag."),
|
||||||
|
limit: z
|
||||||
|
.union([z.literal(50), z.literal(100), z.literal(250)])
|
||||||
|
.optional()
|
||||||
|
.describe("Maximum rows to return. Defaults to 100."),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const listSavedKeywordsTool = {
|
export const listSavedKeywordsTool = {
|
||||||
@ -14,23 +29,33 @@ export const listSavedKeywordsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "List saved keywords",
|
title: "List saved keywords",
|
||||||
description:
|
description:
|
||||||
"Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC if available). Free — reads from OpenSEO's database, no DataForSEO call.",
|
"Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC, and tags if available). Free — reads from OpenSEO's database, no DataForSEO call. Use tag filters when the user asks for a saved segment; multiple tags match ANY tag.",
|
||||||
inputSchema,
|
inputSchema,
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(
|
handler: withMcpProjectAuth(
|
||||||
async (args: z.infer<z.ZodObject<typeof inputSchema>>, context) => {
|
async (args: z.infer<z.ZodObject<typeof inputSchema>>, context) => {
|
||||||
const { rows } = await KeywordResearchService.getSavedKeywords({
|
const { rows, totalCount, tags } =
|
||||||
|
await KeywordResearchService.getSavedKeywords({
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
|
search: args.search,
|
||||||
|
tagNames: args.tags,
|
||||||
|
page: 1,
|
||||||
|
pageSize: args.limit ?? 100,
|
||||||
|
sort: "createdAt",
|
||||||
|
order: "desc",
|
||||||
});
|
});
|
||||||
const text =
|
const text =
|
||||||
rows.length === 0
|
rows.length === 0
|
||||||
? "No saved keywords yet."
|
? "No saved keywords yet."
|
||||||
: `Saved keywords (${rows.length}):\n` +
|
: `Saved keywords (${rows.length} of ${totalCount}):\n` +
|
||||||
rows
|
rows
|
||||||
.map(
|
.map((row) => {
|
||||||
(r) =>
|
const tagText =
|
||||||
`- ${r.keyword} vol:${r.searchVolume ?? "?"} kd:${r.keywordDifficulty ?? "?"} cpc:${r.cpc != null ? `$${r.cpc.toFixed(2)}` : "?"}`,
|
row.tags.length > 0
|
||||||
)
|
? ` tags:${row.tags.map((tag) => tag.name).join(",")}`
|
||||||
|
: "";
|
||||||
|
return `- ${row.keyword} vol:${row.searchVolume ?? "?"} kd:${row.keywordDifficulty ?? "?"} cpc:${row.cpc != null ? `$${row.cpc.toFixed(2)}` : "?"}${tagText}`;
|
||||||
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
return mcpResponse({
|
return mcpResponse({
|
||||||
text,
|
text,
|
||||||
@ -39,7 +64,7 @@ export const listSavedKeywordsTool = {
|
|||||||
args.projectId,
|
args.projectId,
|
||||||
`/p/${args.projectId}/saved`,
|
`/p/${args.projectId}/saved`,
|
||||||
),
|
),
|
||||||
structuredContent: { rows },
|
structuredContent: { rows, totalCount, tags },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -18,6 +18,19 @@ const inputSchema = {
|
|||||||
.min(1)
|
.min(1)
|
||||||
.max(100)
|
.max(100)
|
||||||
.describe("Keywords to save (1-100)."),
|
.describe("Keywords to save (1-100)."),
|
||||||
|
tags: z
|
||||||
|
.array(z.string().min(1).max(64))
|
||||||
|
.max(20)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional tags to attach to every saved keyword. Ask the user for explicit confirmation before using this, especially when saving many keywords or creating new tag names.",
|
||||||
|
),
|
||||||
|
tagMode: z
|
||||||
|
.enum(["append", "replace"])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"How to apply tags. Defaults to append. Use replace to remove existing tags from these saved keywords before applying the provided tags.",
|
||||||
|
),
|
||||||
locationCode: locationCodeSchema.optional(),
|
locationCode: locationCodeSchema.optional(),
|
||||||
languageCode: languageCodeSchema.optional(),
|
languageCode: languageCodeSchema.optional(),
|
||||||
} as const;
|
} as const;
|
||||||
@ -29,18 +42,34 @@ export const saveKeywordsTool = {
|
|||||||
config: {
|
config: {
|
||||||
title: "Save keywords",
|
title: "Save keywords",
|
||||||
description:
|
description:
|
||||||
"Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op.",
|
"Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op. If tags are provided, missing tags may be created. By default tags are appended; set tagMode=replace to remove existing tags from these saved keywords before applying the provided tags, which is useful for reorganizing keywords into page/topic clusters. Ask the user for confirmation before applying or replacing tags broadly.",
|
||||||
inputSchema,
|
inputSchema,
|
||||||
},
|
},
|
||||||
handler: withMcpProjectAuth(async (args: Args, context) => {
|
handler: withMcpProjectAuth(async (args: Args, context) => {
|
||||||
|
if (args.tagMode === "replace" && (args.tags?.length ?? 0) === 0) {
|
||||||
|
throw new Error("Replacement tags are required when tagMode is replace.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationCode = args.locationCode ?? DEFAULT_LOCATION_CODE;
|
||||||
|
const languageCode = args.languageCode ?? DEFAULT_LANGUAGE_CODE;
|
||||||
|
|
||||||
await KeywordResearchService.saveKeywords({
|
await KeywordResearchService.saveKeywords({
|
||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
keywords: args.keywords,
|
keywords: args.keywords,
|
||||||
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
|
tags: args.tags,
|
||||||
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
tagMode: args.tagMode ?? "append",
|
||||||
|
locationCode,
|
||||||
|
languageCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tagText =
|
||||||
|
args.tags && args.tags.length > 0
|
||||||
|
? ` with tag(s): ${args.tags.join(", ")}`
|
||||||
|
: "";
|
||||||
|
const modeText = args.tagMode === "replace" ? " Replaced tags." : "";
|
||||||
|
|
||||||
return mcpResponse({
|
return mcpResponse({
|
||||||
text: `Saved ${args.keywords.length} keyword(s) to project ${args.projectId}.`,
|
text: `Saved ${args.keywords.length} keyword(s)${tagText} to project ${args.projectId}.${modeText}`,
|
||||||
meta: buildProjectMeta(
|
meta: buildProjectMeta(
|
||||||
context,
|
context,
|
||||||
args.projectId,
|
args.projectId,
|
||||||
@ -50,8 +79,10 @@ export const saveKeywordsTool = {
|
|||||||
projectId: args.projectId,
|
projectId: args.projectId,
|
||||||
savedCount: args.keywords.length,
|
savedCount: args.keywords.length,
|
||||||
keywords: args.keywords,
|
keywords: args.keywords,
|
||||||
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
|
tags: args.tags ?? [],
|
||||||
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
|
tagMode: args.tagMode ?? "append",
|
||||||
|
locationCode,
|
||||||
|
languageCode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
191
src/server/mcp/tools/saved-keywords-tools.test.ts
Normal file
191
src/server/mcp/tools/saved-keywords-tools.test.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||||
|
import type { ToolExtra } from "@/server/mcp/context";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getProjectForOrganization: vi.fn(),
|
||||||
|
getSavedKeywords: vi.fn(),
|
||||||
|
saveKeywords: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||||
|
ProjectService: {
|
||||||
|
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
|
||||||
|
KeywordResearchService: {
|
||||||
|
getSavedKeywords: mocks.getSavedKeywords,
|
||||||
|
saveKeywords: mocks.saveKeywords,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const authContext = {
|
||||||
|
userId: "user_123",
|
||||||
|
userEmail: "alice@example.com",
|
||||||
|
organizationId: "org_123",
|
||||||
|
clientId: "client_123",
|
||||||
|
scopes: ["mcp"],
|
||||||
|
audience: "https://open-seo.test/mcp",
|
||||||
|
subject: "user_123",
|
||||||
|
baseUrl: "https://open-seo.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolExtra: ToolExtra = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
requestId: 1,
|
||||||
|
sendNotification: vi.fn(),
|
||||||
|
sendRequest: vi.fn(),
|
||||||
|
authInfo: {
|
||||||
|
token: "token",
|
||||||
|
clientId: "client_123",
|
||||||
|
scopes: ["mcp"],
|
||||||
|
resource: new URL("https://open-seo.test/mcp"),
|
||||||
|
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
|
||||||
|
} satisfies AuthInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("saved keyword MCP tools", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
mocks.getProjectForOrganization.mockReset();
|
||||||
|
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
|
||||||
|
mocks.getSavedKeywords.mockReset();
|
||||||
|
mocks.saveKeywords.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes tags through save_keywords", async () => {
|
||||||
|
mocks.saveKeywords.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
savedKeywordIds: ["saved_1"],
|
||||||
|
});
|
||||||
|
const { saveKeywordsTool } = await import("./save-keywords");
|
||||||
|
|
||||||
|
const result = await saveKeywordsTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["technical seo"],
|
||||||
|
tags: ["Content"],
|
||||||
|
},
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.saveKeywords).toHaveBeenCalledWith({
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["technical seo"],
|
||||||
|
tags: ["Content"],
|
||||||
|
tagMode: "append",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
});
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
savedCount: 1,
|
||||||
|
tags: ["Content"],
|
||||||
|
tagMode: "append",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces tags through save_keywords when requested", async () => {
|
||||||
|
mocks.saveKeywords.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
savedKeywordIds: ["saved_1", "saved_2"],
|
||||||
|
});
|
||||||
|
const { saveKeywordsTool } = await import("./save-keywords");
|
||||||
|
|
||||||
|
const result = await saveKeywordsTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["semrush alternative", "semrush pricing"],
|
||||||
|
tags: ["cluster: affordable semrush alternatives"],
|
||||||
|
tagMode: "replace",
|
||||||
|
},
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.saveKeywords).toHaveBeenCalledWith({
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["semrush alternative", "semrush pricing"],
|
||||||
|
tags: ["cluster: affordable semrush alternatives"],
|
||||||
|
tagMode: "replace",
|
||||||
|
locationCode: 2840,
|
||||||
|
languageCode: "en",
|
||||||
|
});
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
savedCount: 2,
|
||||||
|
tags: ["cluster: affordable semrush alternatives"],
|
||||||
|
tagMode: "replace",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects replace mode without replacement tags before saving", async () => {
|
||||||
|
const { saveKeywordsTool } = await import("./save-keywords");
|
||||||
|
|
||||||
|
await expect(() =>
|
||||||
|
saveKeywordsTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
keywords: ["semrush alternative"],
|
||||||
|
tagMode: "replace",
|
||||||
|
},
|
||||||
|
toolExtra,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Replacement tags are required");
|
||||||
|
expect(mocks.saveKeywords).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters list_saved_keywords by search and tag names", async () => {
|
||||||
|
mocks.getSavedKeywords.mockResolvedValue({
|
||||||
|
totalCount: 1,
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
id: "tag_1",
|
||||||
|
name: "Content",
|
||||||
|
normalizedName: "content",
|
||||||
|
keywordCount: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: "saved_1",
|
||||||
|
keyword: "technical seo",
|
||||||
|
searchVolume: 120,
|
||||||
|
keywordDifficulty: 18,
|
||||||
|
cpc: 2.5,
|
||||||
|
tags: [{ id: "tag_1", name: "Content", normalizedName: "content" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { listSavedKeywordsTool } = await import("./list-saved-keywords");
|
||||||
|
|
||||||
|
const result = await listSavedKeywordsTool.handler(
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
search: "technical",
|
||||||
|
tags: ["Content"],
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
toolExtra,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.getSavedKeywords).toHaveBeenCalledWith({
|
||||||
|
projectId: "project_1",
|
||||||
|
search: "technical",
|
||||||
|
tagNames: ["Content"],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
sort: "createdAt",
|
||||||
|
order: "desc",
|
||||||
|
});
|
||||||
|
expect(result.structuredContent).toMatchObject({
|
||||||
|
totalCount: 1,
|
||||||
|
rows: [{ keyword: "technical seo" }],
|
||||||
|
});
|
||||||
|
const [content] = result.content;
|
||||||
|
expect(content).toMatchObject({ type: "text" });
|
||||||
|
expect(content?.type === "text" ? content.text : "").toContain(
|
||||||
|
"tags:Content",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,10 +1,14 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import {
|
import {
|
||||||
|
deleteSavedKeywordTagSchema,
|
||||||
researchKeywordsSchema,
|
researchKeywordsSchema,
|
||||||
saveKeywordsSchema,
|
saveKeywordsSchema,
|
||||||
getSavedKeywordsSchema,
|
getSavedKeywordsSchema,
|
||||||
|
exportSavedKeywordsSchema,
|
||||||
removeSavedKeywordsSchema,
|
removeSavedKeywordsSchema,
|
||||||
serpAnalysisSchema,
|
serpAnalysisSchema,
|
||||||
|
updateSavedKeywordTagSchema,
|
||||||
|
updateSavedKeywordTagsSchema,
|
||||||
} from "@/types/schemas/keywords";
|
} from "@/types/schemas/keywords";
|
||||||
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
|
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
|
||||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||||
@ -42,6 +46,46 @@ export const getSavedKeywords = createServerFn({ method: "POST" })
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const exportSavedKeywords = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => exportSavedKeywordsSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
return KeywordResearchService.exportSavedKeywords({
|
||||||
|
...data,
|
||||||
|
projectId: context.projectId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateSavedKeywordTags = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => updateSavedKeywordTagsSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
return KeywordResearchService.updateSavedKeywordTags({
|
||||||
|
...data,
|
||||||
|
projectId: context.projectId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateSavedKeywordTag = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => updateSavedKeywordTagSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
return KeywordResearchService.updateSavedKeywordTag({
|
||||||
|
...data,
|
||||||
|
projectId: context.projectId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteSavedKeywordTag = createServerFn({ method: "POST" })
|
||||||
|
.middleware(requireProjectContext)
|
||||||
|
.inputValidator((data: unknown) => deleteSavedKeywordTagSchema.parse(data))
|
||||||
|
.handler(async ({ data, context }) => {
|
||||||
|
return KeywordResearchService.deleteSavedKeywordTag({
|
||||||
|
...data,
|
||||||
|
projectId: context.projectId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export const removeSavedKeywords = createServerFn({
|
export const removeSavedKeywords = createServerFn({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
})
|
})
|
||||||
|
|||||||
32
src/shared/saved-keyword-tags.test.ts
Normal file
32
src/shared/saved-keyword-tags.test.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeSavedKeywordTag,
|
||||||
|
normalizeSavedKeywordTags,
|
||||||
|
parseSavedKeywordTagInput,
|
||||||
|
} from "./saved-keyword-tags";
|
||||||
|
|
||||||
|
describe("saved keyword tag helpers", () => {
|
||||||
|
it("normalizes display and lookup names", () => {
|
||||||
|
expect(normalizeSavedKeywordTag(" Technical SEO ")).toEqual({
|
||||||
|
name: "Technical SEO",
|
||||||
|
normalizedName: "technical seo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes tags by normalized name", () => {
|
||||||
|
expect(
|
||||||
|
normalizeSavedKeywordTags(["Content", "content", " technical seo "]),
|
||||||
|
).toEqual([
|
||||||
|
{ name: "Content", normalizedName: "content" },
|
||||||
|
{ name: "technical seo", normalizedName: "technical seo" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses comma and newline separated tag input", () => {
|
||||||
|
expect(parseSavedKeywordTagInput("content, technical seo\nBOFU")).toEqual([
|
||||||
|
"content",
|
||||||
|
"technical seo",
|
||||||
|
"BOFU",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
src/shared/saved-keyword-tags.ts
Normal file
35
src/shared/saved-keyword-tags.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const TAG_SEPARATOR = /[\n,]+/;
|
||||||
|
|
||||||
|
type NormalizedSavedKeywordTag = {
|
||||||
|
name: string;
|
||||||
|
normalizedName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeSavedKeywordTag(
|
||||||
|
value: string,
|
||||||
|
): NormalizedSavedKeywordTag | null {
|
||||||
|
const name = value.trim().replace(/\s+/g, " ");
|
||||||
|
if (name.length === 0) return null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
normalizedName: name.toLocaleLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSavedKeywordTags(
|
||||||
|
values: readonly string[] | undefined,
|
||||||
|
): NormalizedSavedKeywordTag[] {
|
||||||
|
const tags = new Map<string, NormalizedSavedKeywordTag>();
|
||||||
|
for (const value of values ?? []) {
|
||||||
|
const tag = normalizeSavedKeywordTag(value);
|
||||||
|
if (!tag || tags.has(tag.normalizedName)) continue;
|
||||||
|
tags.set(tag.normalizedName, tag);
|
||||||
|
}
|
||||||
|
return [...tags.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSavedKeywordTagInput(value: string): string[] {
|
||||||
|
return normalizeSavedKeywordTags(value.split(TAG_SEPARATOR)).map(
|
||||||
|
(tag) => tag.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/shared/tag-colors.ts
Normal file
58
src/shared/tag-colors.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export const TAG_COLOR_KEYS = [
|
||||||
|
"slate",
|
||||||
|
"rose",
|
||||||
|
"amber",
|
||||||
|
"lime",
|
||||||
|
"emerald",
|
||||||
|
"sky",
|
||||||
|
"violet",
|
||||||
|
"fuchsia",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type TagColorKey = (typeof TAG_COLOR_KEYS)[number];
|
||||||
|
|
||||||
|
function isTagColorKey(value: unknown): value is TagColorKey {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(TAG_COLOR_KEYS as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashString(value: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTagColor(tag: {
|
||||||
|
id: string;
|
||||||
|
color?: string | null;
|
||||||
|
}): TagColorKey {
|
||||||
|
if (isTagColorKey(tag.color)) return tag.color;
|
||||||
|
return TAG_COLOR_KEYS[hashString(tag.id) % TAG_COLOR_KEYS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_CLASS: Record<TagColorKey, string> = {
|
||||||
|
slate: "bg-slate-500",
|
||||||
|
rose: "bg-rose-500",
|
||||||
|
amber: "bg-amber-500",
|
||||||
|
lime: "bg-lime-500",
|
||||||
|
emerald: "bg-emerald-500",
|
||||||
|
sky: "bg-sky-500",
|
||||||
|
violet: "bg-violet-500",
|
||||||
|
fuchsia: "bg-fuchsia-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tagChipClass(color: TagColorKey): string {
|
||||||
|
return `tag-chip-${color} ring-1 ring-inset`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagDotClass(color: TagColorKey): string {
|
||||||
|
return COLOR_CLASS[color];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tagSwatchClass(color: TagColorKey): string {
|
||||||
|
return COLOR_CLASS[color];
|
||||||
|
}
|
||||||
@ -35,6 +35,19 @@ export type SavedKeywordRow = {
|
|||||||
intent: string | null;
|
intent: string | null;
|
||||||
monthlySearches: MonthlySearch[];
|
monthlySearches: MonthlySearch[];
|
||||||
fetchedAt: string | null;
|
fetchedAt: string | null;
|
||||||
|
tags: SavedKeywordTag[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedKeywordTag = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
normalizedName: string;
|
||||||
|
/** Palette key (e.g. "blue"). Null = derive a stable color from the id. */
|
||||||
|
color: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SavedKeywordTagSummary = SavedKeywordTag & {
|
||||||
|
keywordCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SerpResultItem = {
|
export type SerpResultItem = {
|
||||||
|
|||||||
@ -1,4 +1,18 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { TAG_COLOR_KEYS } from "@/shared/tag-colors";
|
||||||
|
|
||||||
|
const savedKeywordTagSchema = z.string().trim().min(1).max(64);
|
||||||
|
const tagColorSchema = z.enum(TAG_COLOR_KEYS);
|
||||||
|
const savedKeywordSortFields = [
|
||||||
|
"createdAt",
|
||||||
|
"keyword",
|
||||||
|
"searchVolume",
|
||||||
|
"cpc",
|
||||||
|
"competition",
|
||||||
|
"keywordDifficulty",
|
||||||
|
"fetchedAt",
|
||||||
|
] as const;
|
||||||
|
const sortDirs = ["asc", "desc"] as const;
|
||||||
|
|
||||||
export const researchKeywordsSchema = z.object({
|
export const researchKeywordsSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
@ -14,11 +28,14 @@ export const researchKeywordsSchema = z.object({
|
|||||||
.default("auto"),
|
.default("auto"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const saveKeywordsSchema = z.object({
|
export const saveKeywordsSchema = z
|
||||||
|
.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
keywords: z.array(z.string().min(1)).min(1).max(500),
|
keywords: z.array(z.string().min(1)).min(1).max(500),
|
||||||
locationCode: z.number().int().positive().default(2840),
|
locationCode: z.number().int().positive().default(2840),
|
||||||
languageCode: z.string().min(2).max(8).default("en"),
|
languageCode: z.string().min(2).max(8).default("en"),
|
||||||
|
tags: z.array(savedKeywordTagSchema).max(20).optional(),
|
||||||
|
tagMode: z.enum(["append", "replace"]).optional(),
|
||||||
metrics: z
|
metrics: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@ -56,7 +73,11 @@ export const saveKeywordsSchema = z.object({
|
|||||||
)
|
)
|
||||||
.max(500)
|
.max(500)
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
})
|
||||||
|
.refine(
|
||||||
|
(value) => value.tagMode !== "replace" || (value.tags?.length ?? 0) > 0,
|
||||||
|
"Replacement tags are required when tagMode is replace.",
|
||||||
|
);
|
||||||
|
|
||||||
export const removeSavedKeywordsSchema = z.object({
|
export const removeSavedKeywordsSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
@ -65,6 +86,58 @@ export const removeSavedKeywordsSchema = z.object({
|
|||||||
|
|
||||||
export const getSavedKeywordsSchema = z.object({
|
export const getSavedKeywordsSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
|
search: z.string().trim().max(200).optional(),
|
||||||
|
includeTerms: z.array(z.string().trim().min(1)).max(20).optional(),
|
||||||
|
excludeTerms: z.array(z.string().trim().min(1)).max(20).optional(),
|
||||||
|
minVolume: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
maxVolume: z.number().int().nonnegative().nullable().optional(),
|
||||||
|
minCpc: z.number().nonnegative().nullable().optional(),
|
||||||
|
maxCpc: z.number().nonnegative().nullable().optional(),
|
||||||
|
minDifficulty: z.number().int().min(0).max(100).nullable().optional(),
|
||||||
|
maxDifficulty: z.number().int().min(0).max(100).nullable().optional(),
|
||||||
|
tagIds: z.array(z.string().min(1)).max(50).optional(),
|
||||||
|
tagNames: z.array(savedKeywordTagSchema).max(50).optional(),
|
||||||
|
page: z.number().int().positive().default(1),
|
||||||
|
pageSize: z
|
||||||
|
.union([z.literal(50), z.literal(100), z.literal(250)])
|
||||||
|
.default(50),
|
||||||
|
sort: z.enum(savedKeywordSortFields).default("createdAt"),
|
||||||
|
order: z.enum(sortDirs).default("desc"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const exportSavedKeywordsSchema = getSavedKeywordsSchema.omit({
|
||||||
|
page: true,
|
||||||
|
pageSize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateSavedKeywordTagsSchema = z
|
||||||
|
.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
savedKeywordIds: z.array(z.string().min(1)).min(1).max(2000),
|
||||||
|
addTags: z.array(savedKeywordTagSchema).max(20).optional(),
|
||||||
|
removeTagIds: z.array(z.string().min(1)).max(50).optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
(value.addTags?.length ?? 0) > 0 || (value.removeTagIds?.length ?? 0) > 0,
|
||||||
|
"Add or remove at least one tag.",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateSavedKeywordTagSchema = z
|
||||||
|
.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
tagId: z.string().min(1),
|
||||||
|
name: savedKeywordTagSchema.optional(),
|
||||||
|
color: tagColorSchema.nullable().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(value) => value.name !== undefined || value.color !== undefined,
|
||||||
|
"Provide a name or color to update.",
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteSavedKeywordTagSchema = z.object({
|
||||||
|
projectId: z.string().min(1),
|
||||||
|
tagId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
|
export type ResearchKeywordsInput = z.infer<typeof researchKeywordsSchema>;
|
||||||
@ -72,6 +145,19 @@ export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
|
|||||||
export type RemoveSavedKeywordsInput = z.infer<
|
export type RemoveSavedKeywordsInput = z.infer<
|
||||||
typeof removeSavedKeywordsSchema
|
typeof removeSavedKeywordsSchema
|
||||||
>;
|
>;
|
||||||
|
export type GetSavedKeywordsInput = z.infer<typeof getSavedKeywordsSchema>;
|
||||||
|
export type ExportSavedKeywordsInput = z.infer<
|
||||||
|
typeof exportSavedKeywordsSchema
|
||||||
|
>;
|
||||||
|
export type UpdateSavedKeywordTagsInput = z.infer<
|
||||||
|
typeof updateSavedKeywordTagsSchema
|
||||||
|
>;
|
||||||
|
export type UpdateSavedKeywordTagInput = z.infer<
|
||||||
|
typeof updateSavedKeywordTagSchema
|
||||||
|
>;
|
||||||
|
export type DeleteSavedKeywordTagInput = z.infer<
|
||||||
|
typeof deleteSavedKeywordTagSchema
|
||||||
|
>;
|
||||||
export const serpAnalysisSchema = z.object({
|
export const serpAnalysisSchema = z.object({
|
||||||
projectId: z.string().min(1),
|
projectId: z.string().min(1),
|
||||||
keyword: z.string().min(1),
|
keyword: z.string().min(1),
|
||||||
@ -79,8 +165,6 @@ export const serpAnalysisSchema = z.object({
|
|||||||
languageCode: z.string().min(2).max(8).default("en"),
|
languageCode: z.string().min(2).max(8).default("en"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GetSavedKeywordsInput = z.infer<typeof getSavedKeywordsSchema>;
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* URL search params schema for /p/$projectId/keywords */
|
/* URL search params schema for /p/$projectId/keywords */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@ -93,7 +177,6 @@ const keywordSortFields = [
|
|||||||
"keywordDifficulty",
|
"keywordDifficulty",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const sortDirs = ["asc", "desc"] as const;
|
|
||||||
const keywordModes = ["auto", "related", "suggestions", "ideas"] as const;
|
const keywordModes = ["auto", "related", "suggestions", "ideas"] as const;
|
||||||
|
|
||||||
export const keywordsSearchSchema = z.object({
|
export const keywordsSearchSchema = z.object({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user