Add keyword tags, server-side saved filtering, and filtered exports (#179)

This commit is contained in:
Ben Senescu 2026-05-12 00:02:43 -04:00 committed by GitHub
parent bb08abed92
commit 570fe2aa62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 9947 additions and 471 deletions

View 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`);

View File

@ -0,0 +1 @@
ALTER TABLE `saved_keyword_tags` ADD `color` text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,20 @@
"when": 1778113978173,
"tag": "0012_closed_impossible_man",
"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
}
]
}

View File

@ -2,12 +2,17 @@ import { scoreTierClass } from "@/client/features/keywords/utils";
export function DifficultyBadge({ value }: { value: number | null }) {
if (value == null) {
return <span className="badge badge-ghost">-</span>;
}
return (
<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}
</span>

View File

@ -16,7 +16,8 @@ import {
type SortDir,
type SortField,
} 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 { EmptyFilterResults } from "./keywordResearchDesktopFilters";
@ -156,7 +157,7 @@ export function KeywordResearchDesktopTable({
className="justify-end"
/>
),
cell: ({ getValue }) => <ScoreCell value={getValue()} />,
cell: ({ getValue }) => <DifficultyBadge value={getValue()} />,
meta: { headerClassName: "text-right", cellClassName: "text-right" },
}),
keywordColumnHelper.accessor("intent", {
@ -214,15 +215,3 @@ export function KeywordResearchDesktopTable({
</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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View File

@ -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">
&ldquo;{trimmedQuery}&rdquo;
</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&apos;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>
);
}

View 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>
);
}

View 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}
</>
);
}

View 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&apos;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>
);
}

View 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>
);
}

View File

@ -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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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;
}

View 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();
}

View 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),
);
}

View File

@ -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"];

View 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 };
}

View File

@ -169,34 +169,141 @@ select {
@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 {
@apply border text-white;
border-color: color-mix(in oklab, currentColor 26%, transparent);
@apply ring-1 ring-inset;
}
.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 {
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 {
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 {
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 {
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 {
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 {
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"] {
@ -209,35 +316,6 @@ html[data-theme="openseo-dark"] {
--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 {
@apply border-warning/60 bg-warning/20;
}

View File

@ -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.
// This is joined onto savedKeywords when rendering the saved keyword list.
export const keywordMetrics = sqliteTable(

View File

@ -1,358 +1,340 @@
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 { 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 {
getSavedKeywords,
removeSavedKeywords,
updateSavedKeywordTags,
} from "@/serverFunctions/keywords";
import {
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";
import type { SavedKeywordTag } from "@/types/keywords";
export const Route = createFileRoute("/_project/p/$projectId/saved")({
component: SavedKeywordsPage,
});
type SavedKeyword = {
id: string;
keyword: string;
searchVolume: number | null;
cpc: number | null;
competition: number | null;
keywordDifficulty: number | null;
intent: string | null;
fetchedAt: string | null;
};
const FILTER_DEBOUNCE_MS = 350;
function SavedKeywordsPage() {
const { projectId } = Route.useParams();
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 [selected, setSelected] = useState<Set<string>>(new Set());
const [showConfirm, setShowConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [showTagModal, setShowTagModal] = useState(false);
const { data: savedKeywordsData, isLoading } = useQuery({
queryKey: ["savedKeywords", projectId],
queryFn: () => getSavedKeywords({ data: { projectId } }),
const filters = useSavedKeywordsFilters();
const [committedFilterValues, setCommittedFilterValues] = useState(
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({
mutationFn: (savedKeywordIds: string[]) =>
removeSavedKeywords({ data: { projectId, savedKeywordIds } }),
});
const handleDeleteSelected = async () => {
const ids = [...selected];
if (ids.length === 0) return;
setDeleting(true);
setRemoveError(null);
try {
await removeMutation.mutateAsync(ids);
setSelected(new Set());
onSuccess: (result) => {
setRowSelection({});
setShowConfirm(false);
setRemoveError(null);
void invalidateSavedKeywords();
captureClientEvent("saved_keywords:bulk_remove", {
count: ids.length,
count: result.deletedCount,
});
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."));
} finally {
void queryClient.invalidateQueries({
queryKey: ["savedKeywords", projectId],
},
});
setDeleting(false);
}
};
const handleCopySelected = () => {
const keywords = savedKeywords
.filter((kw) => selected.has(kw.id))
.map((kw) => kw.keyword);
void navigator.clipboard.writeText(keywords.join("\n"));
const tagMutation = useMutation({
mutationFn: (input: {
savedKeywordIds: string[];
addTags?: string[];
removeTagIds?: string[];
}) =>
updateSavedKeywordTags({
data: {
projectId,
savedKeywordIds: input.savedKeywordIds,
addTags: input.addTags,
removeTagIds: input.removeTagIds,
},
}),
onSuccess: (result) => {
setRowSelection({});
setShowTagModal(false);
void invalidateSavedKeywords();
toast.success(
`${keywords.length} keyword${keywords.length !== 1 ? "s" : ""} copied`,
`Updated tags for ${result.taggedCount} keyword${result.taggedCount !== 1 ? "s" : ""}`,
);
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
},
onError: (error) => {
toast.error(getStandardErrorMessage(error, "Could not update tags"));
},
});
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 = () => {
if (selected.size === savedKeywords.length) {
setSelected(new Set());
} else {
setSelected(new Set(savedKeywords.map((kw) => kw.id)));
const handleDeleteTag = async (tagId: string) => {
const ok = await tagManage.deleteTag(tagId);
if (ok) {
setSelectedTagIds((current) => current.filter((id) => id !== tagId));
}
};
const savedHeaders = [...KEYWORD_RESEARCH_HEADERS, "Fetched At"];
const sheetsExportRows: CsvValue[][] = savedKeywords.map((kw) => [
kw.keyword,
kw.searchVolume ?? "",
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,
});
const handleClearAllFilters = () => {
filters.resetFilters();
setSelectedTagIds([]);
setPage(1);
};
return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Saved Keywords</h1>
<p className="text-sm text-base-content/70">
Keywords you&apos;ve saved from keyword research.
</p>
</div>
{savedKeywords.length > 0 && (
<div className="flex items-center gap-2">
<ExportToSheetsButton
headers={savedHeaders}
rows={sheetsExportRows}
feature="saved_keywords"
className="btn-sm"
<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-6xl space-y-4">
<SavedKeywordsHeader
totalCount={totalCount}
exporting={exporter.exporting}
onExportCsv={() => void exporter.exportFilteredCsv()}
onExportSheets={() => void exporter.exportFilteredSheets()}
/>
<div className="overflow-hidden rounded-lg border border-base-300 bg-base-100">
<SavedKeywordsFilters
filtersForm={filters.filtersForm}
activeFilterCount={filters.activeFilterCount}
showFilters={showFilters}
onToggleFilters={() => setShowFilters((v) => !v)}
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>
{isLoading ? (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3" aria-busy>
<div className="skeleton h-4 w-48" />
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="grid grid-cols-8 gap-3 items-center"
>
<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>
<SavedKeywordsPagination
page={page}
pageSize={pageSize}
totalCount={totalCount}
isLoading={isFetching}
onPageChange={setPage}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(1);
}}
/>
</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}
{/* Bulk action bar or keyword count */}
{selected.size > 0 ? (
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
<span className="text-base-content/70">
{selected.size} keyword
{selected.size !== 1 ? "s" : ""} selected
</span>
<button
className="btn btn-ghost btn-xs gap-1"
onClick={handleCopySelected}
>
<Copy className="size-3" />
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
{showTagModal ? (
<SavedKeywordsBulkTagsModal
availableTags={availableTags}
selectedCount={selectedCount}
selectedRowTags={selectedRowTags}
isPending={tagMutation.isPending}
onClose={() => setShowTagModal(false)}
onApply={({ addTags, removeTagIds }) =>
tagMutation.mutate({
savedKeywordIds: selectedIds,
addTags,
removeTagIds,
})
}
onChange={toggleAll}
/>
</th>
<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>
)}
) : null}
</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);
}

View File

@ -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 { 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: {
projectId: string;
@ -63,8 +118,8 @@ async function saveKeywordsToProject(params: {
keywords: string[];
locationCode: number;
languageCode: string;
}) {
if (params.keywords.length === 0) return;
}): Promise<SavedKeywordRecord[]> {
if (params.keywords.length === 0) return [];
const [first, ...rest] = params.keywords.map((keyword) =>
db
@ -80,27 +135,208 @@ async function saveKeywordsToProject(params: {
);
await db.batch([first, ...rest]);
return listSavedKeywordRowsByKeywords(params);
}
async function listSavedKeywordsByProject(projectId: string) {
return db
.select({ row: savedKeywords, metric: keywordMetrics })
.from(savedKeywords)
.leftJoin(
keywordMetrics,
and(
function escapeLike(value: string) {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
function buildSavedKeywordWhere(params: {
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[];
}) {
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.projectId, savedKeywords.projectId),
eq(keywordMetrics.locationCode, savedKeywords.locationCode),
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
// projectId filter.
const QUERY_CHUNK_SIZE = 80;
const DELETE_CHUNK_SIZE = 90;
async function removeSavedKeywords(
@ -129,5 +365,14 @@ export const KeywordResearchRepository = {
countSavedKeywords,
saveKeywordsToProject,
listSavedKeywordsByProject,
addTagsToSavedKeywords: SavedKeywordTagsRepository.addTagsToSavedKeywords,
replaceTagsForSavedKeywords:
SavedKeywordTagsRepository.replaceTagsForSavedKeywords,
removeTagsFromSavedKeywords:
SavedKeywordTagsRepository.removeTagsFromSavedKeywords,
removeAllTagsFromSavedKeywords:
SavedKeywordTagsRepository.removeAllTagsFromSavedKeywords,
updateSavedKeywordTag: SavedKeywordTagsRepository.updateSavedKeywordTag,
deleteSavedKeywordTag: SavedKeywordTagsRepository.deleteSavedKeywordTag,
removeSavedKeywords,
} as const;

View File

@ -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;

View File

@ -1,9 +1,13 @@
import {
deleteSavedKeywordTag,
getSavedKeywords,
getSerpAnalysis,
removeSavedKeywords,
research,
saveKeywords,
exportSavedKeywords,
updateSavedKeywordTag,
updateSavedKeywordTags,
} from "@/server/features/keywords/services/research";
export const KeywordResearchService = {
@ -11,5 +15,9 @@ export const KeywordResearchService = {
getSerpAnalysis,
saveKeywords,
getSavedKeywords,
exportSavedKeywords,
updateSavedKeywordTags,
updateSavedKeywordTag,
deleteSavedKeywordTag,
removeSavedKeywords,
} as const;

View File

@ -3,5 +3,9 @@ export { getSerpAnalysis } from "./serp";
export {
saveKeywords,
getSavedKeywords,
exportSavedKeywords,
updateSavedKeywordTags,
updateSavedKeywordTag,
deleteSavedKeywordTag,
removeSavedKeywords,
} from "./saved-keywords";

View File

@ -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();
});
});

View File

@ -1,11 +1,19 @@
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
import { jsonCodec } from "@/shared/json";
import type {
DeleteSavedKeywordTagInput,
ExportSavedKeywordsInput,
GetSavedKeywordsInput,
RemoveSavedKeywordsInput,
SaveKeywordsInput,
UpdateSavedKeywordTagInput,
UpdateSavedKeywordTagsInput,
} from "@/types/schemas/keywords";
import type { MonthlySearch, SavedKeywordRow } from "@/types/keywords";
import type {
MonthlySearch,
SavedKeywordRow,
SavedKeywordTagSummary,
} from "@/types/keywords";
import { normalizeKeyword } from "./helpers";
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,
keywords: normalizedKeywords,
locationCode: input.locationCode,
languageCode: input.languageCode,
});
const savedKeywordIds = savedRows.map((row) => row.id);
return { success: true };
}
export async function getSavedKeywords(
input: GetSavedKeywordsInput,
): Promise<{ rows: SavedKeywordRow[] }> {
const rows = await KeywordResearchRepository.listSavedKeywordsByProject(
input.projectId,
);
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 ?? [],
});
}
return {
rows: rows.map(({ row, metric }) => ({
success: true,
savedKeywordIds,
};
}
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 {
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,
projectId: row.projectId,
keyword: row.keyword,
@ -101,10 +220,69 @@ export async function getSavedKeywords(
intent: metric?.intent ?? null,
monthlySearches: parseMonthlySearches(metric?.monthlySearches ?? 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(
projectId: string,
input: RemoveSavedKeywordsInput,

View File

@ -1,4 +1,4 @@
import type { z } from "zod";
import { z } from "zod";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
@ -7,6 +7,21 @@ import { projectIdSchema } from "@/server/mcp/schemas";
const inputSchema = {
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;
export const listSavedKeywordsTool = {
@ -14,23 +29,33 @@ export const listSavedKeywordsTool = {
config: {
title: "List saved keywords",
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,
},
handler: withMcpProjectAuth(
async (args: z.infer<z.ZodObject<typeof inputSchema>>, context) => {
const { rows } = await KeywordResearchService.getSavedKeywords({
const { rows, totalCount, tags } =
await KeywordResearchService.getSavedKeywords({
projectId: args.projectId,
search: args.search,
tagNames: args.tags,
page: 1,
pageSize: args.limit ?? 100,
sort: "createdAt",
order: "desc",
});
const text =
rows.length === 0
? "No saved keywords yet."
: `Saved keywords (${rows.length}):\n` +
: `Saved keywords (${rows.length} of ${totalCount}):\n` +
rows
.map(
(r) =>
`- ${r.keyword} vol:${r.searchVolume ?? "?"} kd:${r.keywordDifficulty ?? "?"} cpc:${r.cpc != null ? `$${r.cpc.toFixed(2)}` : "?"}`,
)
.map((row) => {
const tagText =
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");
return mcpResponse({
text,
@ -39,7 +64,7 @@ export const listSavedKeywordsTool = {
args.projectId,
`/p/${args.projectId}/saved`,
),
structuredContent: { rows },
structuredContent: { rows, totalCount, tags },
});
},
),

View File

@ -18,6 +18,19 @@ const inputSchema = {
.min(1)
.max(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(),
languageCode: languageCodeSchema.optional(),
} as const;
@ -29,18 +42,34 @@ export const saveKeywordsTool = {
config: {
title: "Save keywords",
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,
},
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({
projectId: args.projectId,
keywords: args.keywords,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
tags: args.tags,
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({
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(
context,
args.projectId,
@ -50,8 +79,10 @@ export const saveKeywordsTool = {
projectId: args.projectId,
savedCount: args.keywords.length,
keywords: args.keywords,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
tags: args.tags ?? [],
tagMode: args.tagMode ?? "append",
locationCode,
languageCode,
},
});
}),

View 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",
);
});
});

View File

@ -1,10 +1,14 @@
import { createServerFn } from "@tanstack/react-start";
import {
deleteSavedKeywordTagSchema,
researchKeywordsSchema,
saveKeywordsSchema,
getSavedKeywordsSchema,
exportSavedKeywordsSchema,
removeSavedKeywordsSchema,
serpAnalysisSchema,
updateSavedKeywordTagSchema,
updateSavedKeywordTagsSchema,
} from "@/types/schemas/keywords";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
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({
method: "POST",
})

View 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",
]);
});
});

View 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
View 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];
}

View File

@ -35,6 +35,19 @@ export type SavedKeywordRow = {
intent: string | null;
monthlySearches: MonthlySearch[];
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 = {

View File

@ -1,4 +1,18 @@
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({
projectId: z.string().min(1),
@ -14,11 +28,14 @@ export const researchKeywordsSchema = z.object({
.default("auto"),
});
export const saveKeywordsSchema = z.object({
export const saveKeywordsSchema = z
.object({
projectId: z.string().min(1),
keywords: z.array(z.string().min(1)).min(1).max(500),
locationCode: z.number().int().positive().default(2840),
languageCode: z.string().min(2).max(8).default("en"),
tags: z.array(savedKeywordTagSchema).max(20).optional(),
tagMode: z.enum(["append", "replace"]).optional(),
metrics: z
.array(
z.object({
@ -56,7 +73,11 @@ export const saveKeywordsSchema = z.object({
)
.max(500)
.optional(),
});
})
.refine(
(value) => value.tagMode !== "replace" || (value.tags?.length ?? 0) > 0,
"Replacement tags are required when tagMode is replace.",
);
export const removeSavedKeywordsSchema = z.object({
projectId: z.string().min(1),
@ -65,6 +86,58 @@ export const removeSavedKeywordsSchema = z.object({
export const getSavedKeywordsSchema = z.object({
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>;
@ -72,6 +145,19 @@ export type SaveKeywordsInput = z.infer<typeof saveKeywordsSchema>;
export type RemoveSavedKeywordsInput = z.infer<
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({
projectId: 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"),
});
export type GetSavedKeywordsInput = z.infer<typeof getSavedKeywordsSchema>;
/* ------------------------------------------------------------------ */
/* URL search params schema for /p/$projectId/keywords */
/* ------------------------------------------------------------------ */
@ -93,7 +177,6 @@ const keywordSortFields = [
"keywordDifficulty",
] as const;
const sortDirs = ["asc", "desc"] as const;
const keywordModes = ["auto", "related", "suggestions", "ideas"] as const;
export const keywordsSearchSchema = z.object({