Add keyword tags, server-side saved filtering, and filtered exports (#179)
This commit is contained in:
parent
bb08abed92
commit
570fe2aa62
22
drizzle/0013_fat_network.sql
Normal file
22
drizzle/0013_fat_network.sql
Normal file
@ -0,0 +1,22 @@
|
||||
CREATE TABLE `saved_keyword_tags` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`normalized_name` text NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `saved_keyword_tags_project_normalized_name_idx` ON `saved_keyword_tags` (`project_id`,`normalized_name`);--> statement-breakpoint
|
||||
CREATE INDEX `saved_keyword_tags_project_name_idx` ON `saved_keyword_tags` (`project_id`,`name`);--> statement-breakpoint
|
||||
CREATE TABLE `saved_keyword_tag_assignments` (
|
||||
`saved_keyword_id` text NOT NULL,
|
||||
`tag_id` text NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`saved_keyword_id`) REFERENCES `saved_keywords`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `saved_keyword_tags`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `saved_keyword_tag_assignments_unique_idx` ON `saved_keyword_tag_assignments` (`saved_keyword_id`,`tag_id`);--> statement-breakpoint
|
||||
CREATE INDEX `saved_keyword_tag_assignments_keyword_idx` ON `saved_keyword_tag_assignments` (`saved_keyword_id`);--> statement-breakpoint
|
||||
CREATE INDEX `saved_keyword_tag_assignments_tag_idx` ON `saved_keyword_tag_assignments` (`tag_id`);
|
||||
1
drizzle/0014_tag_color.sql
Normal file
1
drizzle/0014_tag_color.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `saved_keyword_tags` ADD `color` text;
|
||||
2793
drizzle/meta/0013_snapshot.json
Normal file
2793
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2800
drizzle/meta/0014_snapshot.json
Normal file
2800
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -92,6 +92,20 @@
|
||||
"when": 1778113978173,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/client/features/saved-keywords/ManageTagRow.tsx
Normal file
103
src/client/features/saved-keywords/ManageTagRow.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
resolveTagColor,
|
||||
TAG_COLOR_KEYS,
|
||||
tagSwatchClass,
|
||||
type TagColorKey,
|
||||
} from "@/shared/tag-colors";
|
||||
import type { SavedKeywordTagSummary } from "@/types/keywords";
|
||||
|
||||
export function ManageTagRow({
|
||||
tag,
|
||||
isBusy,
|
||||
onSave,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}: {
|
||||
tag: SavedKeywordTagSummary;
|
||||
isBusy: boolean;
|
||||
onSave: (input: { name?: string; color?: TagColorKey | null }) => void;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(tag.name);
|
||||
const currentColor = resolveTagColor(tag);
|
||||
const [color, setColor] = useState<TagColorKey>(currentColor);
|
||||
const nameChanged = name.trim() !== tag.name && name.trim().length > 0;
|
||||
const colorChanged = color !== currentColor;
|
||||
const canSave = (nameChanged || colorChanged) && !isBusy;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-y border-base-300 bg-base-200/40 px-3 py-2.5">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-wide text-base-content/55">
|
||||
Rename
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Pencil className="size-3 opacity-50" />
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-base-300 bg-base-100 px-2 py-1 text-sm outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] font-semibold uppercase tracking-wide text-base-content/55">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{TAG_COLOR_KEYS.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-label={key}
|
||||
className={`size-5 rounded-full transition ${tagSwatchClass(key)} ${
|
||||
color === key
|
||||
? "ring-2 ring-offset-2 ring-offset-base-200 ring-base-content/40"
|
||||
: "hover:scale-110"
|
||||
}`}
|
||||
onClick={() => setColor(key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs text-error hover:underline disabled:opacity-50"
|
||||
onClick={onDelete}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-xs text-base-content/70 hover:bg-base-300"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-content disabled:opacity-50"
|
||||
disabled={!canSave}
|
||||
onClick={() =>
|
||||
onSave({
|
||||
name: nameChanged ? name.trim() : undefined,
|
||||
color: colorChanged ? color : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
FileDown,
|
||||
Loader2,
|
||||
Sheet,
|
||||
Tags,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SavedKeywordsBulkActionBar({
|
||||
selectedCount,
|
||||
onCopy,
|
||||
onOpenTags,
|
||||
onExportCsv,
|
||||
onExportSheets,
|
||||
onDelete,
|
||||
onClear,
|
||||
exportingSelection,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
onCopy: () => void;
|
||||
onOpenTags: () => void;
|
||||
onExportCsv: () => void;
|
||||
onExportSheets: () => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
exportingSelection: "csv" | "sheets" | null;
|
||||
}) {
|
||||
if (selectedCount === 0) return null;
|
||||
const exportBusy = exportingSelection != null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-30 flex justify-center px-4">
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Bulk actions"
|
||||
className="pointer-events-auto flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-300/85 shadow-2xl backdrop-blur"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-r border-base-content/10 px-3 py-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
className="-ml-1 rounded p-1 text-base-content/55 hover:bg-base-content/10 hover:text-base-content"
|
||||
onClick={onClear}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
<span className="font-medium tabular-nums">{selectedCount}</span>
|
||||
<span className="text-base-content/60">selected</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 px-1.5">
|
||||
<ActionButton
|
||||
icon={<Tags className="size-3.5" />}
|
||||
onClick={onOpenTags}
|
||||
>
|
||||
Tag
|
||||
</ActionButton>
|
||||
|
||||
<div className="dropdown dropdown-top dropdown-end">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
disabled={exportBusy}
|
||||
aria-haspopup="menu"
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-base-content/85 hover:bg-base-content/10 disabled:opacity-50"
|
||||
>
|
||||
{exportBusy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-3.5" />
|
||||
)}
|
||||
Export
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
role="menu"
|
||||
className="dropdown-content menu z-10 mb-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<li>
|
||||
<button type="button" onClick={onCopy}>
|
||||
<Copy className="size-4" />
|
||||
Copy keywords
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportSheets}
|
||||
disabled={exportBusy}
|
||||
>
|
||||
<Sheet className="size-4" />
|
||||
Export to Sheets
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportCsv}
|
||||
disabled={exportBusy}
|
||||
>
|
||||
<FileDown className="size-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center border-l border-base-content/10 px-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-error hover:bg-error/10"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm text-base-content/85 hover:bg-base-content/10 disabled:opacity-50"
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,322 @@
|
||||
import { Check, Loader2, Plus, Search, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Modal } from "@/client/components/Modal";
|
||||
import { resolveTagColor, tagDotClass } from "@/shared/tag-colors";
|
||||
import type { SavedKeywordTag, SavedKeywordTagSummary } from "@/types/keywords";
|
||||
import { TagChip } from "./TagChip";
|
||||
|
||||
type Mode = "add" | "remove";
|
||||
|
||||
export function SavedKeywordsBulkTagsModal({
|
||||
availableTags,
|
||||
selectedCount,
|
||||
selectedRowTags,
|
||||
isPending,
|
||||
onClose,
|
||||
onApply,
|
||||
}: {
|
||||
availableTags: SavedKeywordTagSummary[];
|
||||
selectedCount: number;
|
||||
/** Tags currently attached to the selected rows (deduped). Used to show
|
||||
* initial state and to compute which existing tags can be removed. */
|
||||
selectedRowTags: SavedKeywordTag[];
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (input: { addTags?: string[]; removeTagIds?: string[] }) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<Mode>("add");
|
||||
const [query, setQuery] = useState("");
|
||||
const [addNames, setAddNames] = useState<string[]>([]);
|
||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const normalizedAddSet = useMemo(
|
||||
() => new Set(addNames.map((name) => name.toLocaleLowerCase())),
|
||||
[addNames],
|
||||
);
|
||||
|
||||
const availableByNormalized = useMemo(() => {
|
||||
const map = new Map<string, SavedKeywordTagSummary>();
|
||||
for (const tag of availableTags) {
|
||||
map.set(tag.normalizedName, tag);
|
||||
}
|
||||
return map;
|
||||
}, [availableTags]);
|
||||
|
||||
const filteredAvailable = useMemo(() => {
|
||||
const q = query.trim().toLocaleLowerCase();
|
||||
if (!q) return availableTags;
|
||||
return availableTags.filter((tag) => tag.normalizedName.includes(q));
|
||||
}, [availableTags, query]);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const queryNormalized = trimmedQuery.toLocaleLowerCase();
|
||||
const showCreate =
|
||||
mode === "add" &&
|
||||
trimmedQuery.length > 0 &&
|
||||
!availableByNormalized.has(queryNormalized) &&
|
||||
!normalizedAddSet.has(queryNormalized);
|
||||
|
||||
const canApply = !isPending && (addNames.length > 0 || removeIds.length > 0);
|
||||
|
||||
const handleToggleAdd = (tag: SavedKeywordTagSummary) => {
|
||||
setAddNames((current) =>
|
||||
normalizedAddSet.has(tag.normalizedName)
|
||||
? current.filter(
|
||||
(name) => name.toLocaleLowerCase() !== tag.normalizedName,
|
||||
)
|
||||
: [...current, tag.name],
|
||||
);
|
||||
setRemoveIds((current) => current.filter((id) => id !== tag.id));
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!trimmedQuery) return;
|
||||
setAddNames((current) =>
|
||||
current.some((name) => name.toLocaleLowerCase() === queryNormalized)
|
||||
? current
|
||||
: [...current, trimmedQuery],
|
||||
);
|
||||
setQuery("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleToggleRemove = (tag: SavedKeywordTag) => {
|
||||
setRemoveIds((current) =>
|
||||
current.includes(tag.id)
|
||||
? current.filter((id) => id !== tag.id)
|
||||
: [...current, tag.id],
|
||||
);
|
||||
setAddNames((current) =>
|
||||
current.filter((name) => name.toLocaleLowerCase() !== tag.normalizedName),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal maxWidth="max-w-lg" onClose={onClose} labelledBy="bulk-tags-title">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 id="bulk-tags-title" className="text-lg font-semibold">
|
||||
Update tags
|
||||
</h3>
|
||||
<p className="text-sm text-base-content/65">
|
||||
Apply or remove tags across {selectedCount} selected keyword
|
||||
{selectedCount !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex rounded-md border border-base-300 bg-base-200/40 p-0.5 text-sm">
|
||||
<SegmentButton
|
||||
active={mode === "add"}
|
||||
onClick={() => setMode("add")}
|
||||
label="Add tags"
|
||||
count={addNames.length}
|
||||
/>
|
||||
<SegmentButton
|
||||
active={mode === "remove"}
|
||||
onClick={() => setMode("remove")}
|
||||
label="Remove tags"
|
||||
count={removeIds.length}
|
||||
disabled={selectedRowTags.length === 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "add" ? (
|
||||
<div className="space-y-2">
|
||||
{addNames.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-base-300 bg-base-200/40 px-2 py-2">
|
||||
{addNames.map((name) => {
|
||||
const existing = availableByNormalized.get(
|
||||
name.toLocaleLowerCase(),
|
||||
);
|
||||
const tag = existing ?? {
|
||||
id: `new:${name}`,
|
||||
name,
|
||||
normalizedName: name.toLocaleLowerCase(),
|
||||
color: null,
|
||||
};
|
||||
return (
|
||||
<TagChip
|
||||
key={name}
|
||||
tag={tag}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setAddNames((current) =>
|
||||
current.filter(
|
||||
(existingName) => existingName !== name,
|
||||
),
|
||||
)
|
||||
}
|
||||
trailing={<X className="size-3 opacity-70" />}
|
||||
title="Remove from selection"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex items-center gap-2 rounded-md border border-base-300 bg-base-100 px-2 py-2">
|
||||
<Search className="size-3.5 opacity-50" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && showCreate) {
|
||||
event.preventDefault();
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Search or create…"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-base-content/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border border-base-300">
|
||||
{showCreate ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-base-200"
|
||||
>
|
||||
<Plus className="size-3.5 text-primary" />
|
||||
<span className="text-base-content/70">Create</span>
|
||||
<span className="font-medium">
|
||||
“{trimmedQuery}”
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{filteredAvailable.length === 0 && !showCreate ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-base-content/55">
|
||||
{availableTags.length === 0
|
||||
? "No tags yet. Type a name above to create one."
|
||||
: "No tags match that search."}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filteredAvailable.map((tag) => {
|
||||
const checked = normalizedAddSet.has(tag.normalizedName);
|
||||
const color = resolveTagColor(tag);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => handleToggleAdd(tag)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-base-200"
|
||||
>
|
||||
<span
|
||||
className={`flex size-4 shrink-0 items-center justify-center rounded border ${
|
||||
checked
|
||||
? "border-primary bg-primary text-primary-content"
|
||||
: "border-base-300"
|
||||
}`}
|
||||
>
|
||||
{checked ? <Check className="size-3" /> : null}
|
||||
</span>
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${tagDotClass(color)}`}
|
||||
/>
|
||||
<span className="flex-1 truncate text-sm">{tag.name}</span>
|
||||
<span className="text-[11px] tabular-nums text-base-content/45">
|
||||
{tag.keywordCount}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedRowTags.length === 0 ? (
|
||||
<div className="rounded-md border border-base-300 bg-base-200/40 px-3 py-6 text-center text-xs text-base-content/55">
|
||||
The selected keywords don't have any tags to remove.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 rounded-md border border-base-300 p-3">
|
||||
{selectedRowTags.map((tag) => {
|
||||
const checked = removeIds.includes(tag.id);
|
||||
return (
|
||||
<TagChip
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
size="sm"
|
||||
onClick={() => handleToggleRemove(tag)}
|
||||
selected={checked}
|
||||
trailing={checked ? <Check className="size-3" /> : null}
|
||||
title={checked ? "Will be removed" : "Click to remove"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{removeIds.length > 0 ? (
|
||||
<p className="text-xs text-base-content/55">
|
||||
{removeIds.length} tag{removeIds.length !== 1 ? "s" : ""} will
|
||||
be detached from the selected keywords.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md px-3 py-1.5 text-sm text-base-content/70 hover:bg-base-200"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-content disabled:opacity-50"
|
||||
disabled={!canApply}
|
||||
onClick={() =>
|
||||
onApply({
|
||||
addTags: addNames.length > 0 ? addNames : undefined,
|
||||
removeTagIds: removeIds.length > 0 ? removeIds : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{isPending ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
disabled,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-3 py-1 text-sm transition ${
|
||||
active
|
||||
? "bg-base-100 font-medium shadow-sm"
|
||||
: "text-base-content/65 hover:text-base-content"
|
||||
} disabled:opacity-40`}
|
||||
>
|
||||
{label}
|
||||
{count > 0 ? (
|
||||
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-content">
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
282
src/client/features/saved-keywords/SavedKeywordsFilterPanel.tsx
Normal file
282
src/client/features/saved-keywords/SavedKeywordsFilterPanel.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
import { Minus, Plus, RotateCcw, X } from "lucide-react";
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import type { SavedKeywordsFilterValues } from "./savedKeywordsFilterTypes";
|
||||
import type { SavedKeywordsFilterForm } from "./useSavedKeywordsFilters";
|
||||
|
||||
export function SavedKeywordsFilterPanel({
|
||||
form,
|
||||
activeFilterCount,
|
||||
onReset,
|
||||
}: {
|
||||
form: SavedKeywordsFilterForm;
|
||||
activeFilterCount: number;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3 border-b border-base-300 bg-gradient-to-b from-base-100 to-base-200/30 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold">Refine results</p>
|
||||
{activeFilterCount > 0 ? (
|
||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-ghost gap-1"
|
||||
onClick={onReset}
|
||||
disabled={activeFilterCount === 0}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
<TermsTokenInput
|
||||
form={form}
|
||||
name="include"
|
||||
label="Include"
|
||||
variant="include"
|
||||
placeholder="Must contain… e.g. audit"
|
||||
/>
|
||||
<TermsTokenInput
|
||||
form={form}
|
||||
name="exclude"
|
||||
label="Exclude"
|
||||
variant="exclude"
|
||||
placeholder="Must not contain… e.g. jobs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<FilterRangeInputs
|
||||
form={form}
|
||||
title="Search Volume"
|
||||
minName="minVol"
|
||||
maxName="maxVol"
|
||||
min={0}
|
||||
/>
|
||||
<FilterRangeInputs
|
||||
form={form}
|
||||
title="CPC (USD)"
|
||||
minName="minCpc"
|
||||
maxName="maxCpc"
|
||||
step="0.01"
|
||||
min={0}
|
||||
/>
|
||||
<FilterRangeInputs
|
||||
form={form}
|
||||
title="Difficulty"
|
||||
minName="minKd"
|
||||
maxName="maxKd"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TermsVariant = "include" | "exclude";
|
||||
|
||||
const VARIANT_STYLES: Record<
|
||||
TermsVariant,
|
||||
{ icon: typeof Plus; chip: string; iconBg: string }
|
||||
> = {
|
||||
include: {
|
||||
icon: Plus,
|
||||
chip: "tag-chip-emerald ring-1 ring-inset",
|
||||
iconBg: "tag-chip-emerald ring-1 ring-inset",
|
||||
},
|
||||
exclude: {
|
||||
icon: Minus,
|
||||
chip: "tag-chip-rose ring-1 ring-inset",
|
||||
iconBg: "tag-chip-rose ring-1 ring-inset",
|
||||
},
|
||||
};
|
||||
|
||||
function splitTerms(value: string): string[] {
|
||||
return value
|
||||
.split(/[,+]/)
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function joinTerms(terms: string[]): string {
|
||||
return terms.join(", ");
|
||||
}
|
||||
|
||||
function TermsTokenInput({
|
||||
form,
|
||||
name,
|
||||
label,
|
||||
variant,
|
||||
placeholder,
|
||||
}: {
|
||||
form: SavedKeywordsFilterForm;
|
||||
name: "include" | "exclude";
|
||||
label: string;
|
||||
variant: TermsVariant;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const styles = VARIANT_STYLES[variant];
|
||||
const Icon = styles.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-base-300 bg-base-100 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex size-4 items-center justify-center rounded ${styles.iconBg}`}
|
||||
>
|
||||
<Icon className="size-2.5" />
|
||||
</span>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
<form.Field name={name}>
|
||||
{(field) => {
|
||||
const terms = splitTerms(field.state.value);
|
||||
const commit = (next: string[]) => {
|
||||
field.handleChange(joinTerms([...new Set(next)]));
|
||||
};
|
||||
const addFromDraft = () => {
|
||||
const parsed = splitTerms(draft);
|
||||
if (parsed.length > 0) {
|
||||
commit([...terms, ...parsed]);
|
||||
setDraft("");
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
addFromDraft();
|
||||
} else if (
|
||||
event.key === "Backspace" &&
|
||||
draft.length === 0 &&
|
||||
terms.length > 0
|
||||
) {
|
||||
commit(terms.slice(0, -1));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-base-300 bg-base-200/30 px-2 py-1.5 focus-within:border-primary">
|
||||
{terms.map((term) => (
|
||||
<span
|
||||
key={term}
|
||||
className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs ${styles.chip}`}
|
||||
>
|
||||
{term}
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-70 hover:opacity-100"
|
||||
aria-label={`Remove ${term}`}
|
||||
onClick={() =>
|
||||
commit(terms.filter((existing) => existing !== term))
|
||||
}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={addFromDraft}
|
||||
placeholder={terms.length === 0 ? placeholder : ""}
|
||||
className="min-w-[6rem] flex-1 bg-transparent text-xs outline-none placeholder:text-base-content/40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RangeFieldName = Extract<
|
||||
keyof SavedKeywordsFilterValues,
|
||||
"minVol" | "maxVol" | "minCpc" | "maxCpc" | "minKd" | "maxKd"
|
||||
>;
|
||||
|
||||
function FilterRangeInputs({
|
||||
form,
|
||||
title,
|
||||
minName,
|
||||
maxName,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
form: SavedKeywordsFilterForm;
|
||||
title: string;
|
||||
minName: Extract<RangeFieldName, "minVol" | "minCpc" | "minKd">;
|
||||
maxName: Extract<RangeFieldName, "maxVol" | "maxCpc" | "maxKd">;
|
||||
step?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-base-300 bg-base-100 p-2.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
{title}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<CompactRangeInput
|
||||
form={form}
|
||||
name={minName}
|
||||
placeholder="Min"
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
<CompactRangeInput
|
||||
form={form}
|
||||
name={maxName}
|
||||
placeholder="Max"
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactRangeInput({
|
||||
form,
|
||||
name,
|
||||
placeholder,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
form: SavedKeywordsFilterForm;
|
||||
name: RangeFieldName;
|
||||
placeholder: string;
|
||||
step?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}) {
|
||||
return (
|
||||
<form.Field name={name}>
|
||||
{(field) => (
|
||||
<input
|
||||
className="input input-bordered input-xs bg-base-100"
|
||||
placeholder={placeholder}
|
||||
type="number"
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
value={field.state.value}
|
||||
onChange={(event) => field.handleChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
);
|
||||
}
|
||||
76
src/client/features/saved-keywords/SavedKeywordsFilters.tsx
Normal file
76
src/client/features/saved-keywords/SavedKeywordsFilters.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { SavedKeywordsFilterPanel } from "./SavedKeywordsFilterPanel";
|
||||
import { SavedKeywordsTagFilter } from "./SavedKeywordsTagFilter";
|
||||
import type { TagColorKey } from "@/shared/tag-colors";
|
||||
import type { SavedKeywordTagSummary } from "@/types/keywords";
|
||||
import type { SavedKeywordsFilterForm } from "./useSavedKeywordsFilters";
|
||||
|
||||
export function SavedKeywordsFilters({
|
||||
filtersForm,
|
||||
activeFilterCount,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
onResetAllFilters,
|
||||
availableTags,
|
||||
selectedTagIds,
|
||||
busyTagIds,
|
||||
onToggleTagFilter,
|
||||
onClearTagSelection,
|
||||
onUpdateTag,
|
||||
onDeleteTag,
|
||||
}: {
|
||||
filtersForm: SavedKeywordsFilterForm;
|
||||
activeFilterCount: number;
|
||||
showFilters: boolean;
|
||||
onToggleFilters: () => void;
|
||||
onResetAllFilters: () => void;
|
||||
availableTags: SavedKeywordTagSummary[];
|
||||
selectedTagIds: string[];
|
||||
busyTagIds: Set<string>;
|
||||
onToggleTagFilter: (tagId: string) => void;
|
||||
onClearTagSelection: () => void;
|
||||
onUpdateTag: (input: {
|
||||
tagId: string;
|
||||
name?: string;
|
||||
color?: TagColorKey | null;
|
||||
}) => void;
|
||||
onDeleteTag: (tagId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-base-300 px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-ghost btn-sm gap-1.5 ${showFilters ? "btn-active" : ""}`}
|
||||
onClick={onToggleFilters}
|
||||
title="Toggle table filters"
|
||||
>
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
Filters
|
||||
{activeFilterCount > 0 ? (
|
||||
<span className="badge badge-xs badge-primary border-0 text-primary-content">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<SavedKeywordsTagFilter
|
||||
availableTags={availableTags}
|
||||
selectedTagIds={selectedTagIds}
|
||||
busyTagIds={busyTagIds}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
onClearSelection={onClearTagSelection}
|
||||
onUpdateTag={onUpdateTag}
|
||||
onDeleteTag={onDeleteTag}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showFilters ? (
|
||||
<SavedKeywordsFilterPanel
|
||||
form={filtersForm}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onReset={onResetAllFilters}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
src/client/features/saved-keywords/SavedKeywordsHeader.tsx
Normal file
63
src/client/features/saved-keywords/SavedKeywordsHeader.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { ChevronDown, Download, FileDown, Loader2, Sheet } from "lucide-react";
|
||||
|
||||
export function SavedKeywordsHeader({
|
||||
totalCount,
|
||||
exporting,
|
||||
onExportCsv,
|
||||
onExportSheets,
|
||||
}: {
|
||||
totalCount: number;
|
||||
exporting: "csv" | "sheets" | null;
|
||||
onExportCsv: () => void;
|
||||
onExportSheets: () => void;
|
||||
}) {
|
||||
const disabled = totalCount === 0 || exporting != null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Saved Keywords</h1>
|
||||
<p className="text-sm text-base-content/70">
|
||||
Save keyword ideas from research, organize them with tags, and revisit
|
||||
when you're ready to act.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="dropdown dropdown-end">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
className={`btn btn-ghost btn-sm gap-1.5 ${disabled ? "btn-disabled" : ""}`}
|
||||
>
|
||||
{exporting != null ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
Export
|
||||
<ChevronDown className="size-3 opacity-60" />
|
||||
</button>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
role="menu"
|
||||
className="dropdown-content menu z-10 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<li>
|
||||
<button type="button" onClick={onExportSheets} disabled={disabled}>
|
||||
<Sheet className="size-4" />
|
||||
Export to Sheets
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" onClick={onExportCsv} disabled={disabled}>
|
||||
<FileDown className="size-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/client/features/saved-keywords/SavedKeywordsModals.tsx
Normal file
54
src/client/features/saved-keywords/SavedKeywordsModals.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Modal } from "@/client/components/Modal";
|
||||
|
||||
export function RemoveSavedKeywordsError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-error/30 bg-error/10 p-3 text-sm text-error">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteSavedKeywordsModal({
|
||||
selectedCount,
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal onClose={onClose} labelledBy="delete-keywords-title">
|
||||
<h3 id="delete-keywords-title" className="text-lg font-semibold">
|
||||
Delete keywords?
|
||||
</h3>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This will permanently delete {selectedCount} saved keyword
|
||||
{selectedCount !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-sm gap-1"
|
||||
onClick={onConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
Delete {selectedCount} keyword
|
||||
{selectedCount !== 1 ? "s" : ""}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { SAVED_KEYWORD_PAGE_SIZES } from "./savedKeywordsUtils";
|
||||
|
||||
export function SavedKeywordsPagination({
|
||||
page,
|
||||
pageSize,
|
||||
totalCount,
|
||||
isLoading,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: (typeof SAVED_KEYWORD_PAGE_SIZES)[number];
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (
|
||||
pageSize: (typeof SAVED_KEYWORD_PAGE_SIZES)[number],
|
||||
) => void;
|
||||
}) {
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
const start = totalCount === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const end = Math.min(totalCount, page * pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-t border-base-300 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm tabular-nums text-base-content/70">
|
||||
<span>
|
||||
{start.toLocaleString()}-{end.toLocaleString()} of{" "}
|
||||
{totalCount.toLocaleString()}
|
||||
</span>
|
||||
{isLoading ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span className="whitespace-nowrap">Rows per page</span>
|
||||
<select
|
||||
className="select select-bordered select-sm w-20"
|
||||
value={pageSize}
|
||||
onChange={(event) =>
|
||||
onPageSizeChange(parsePageSize(event.target.value))
|
||||
}
|
||||
>
|
||||
{SAVED_KEYWORD_PAGE_SIZES.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-sm tabular-nums text-base-content/70">
|
||||
Page {page.toLocaleString()} of {totalPages.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
disabled={page <= 1 || isLoading}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
disabled={page >= totalPages || isLoading}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePageSize(
|
||||
value: string,
|
||||
): (typeof SAVED_KEYWORD_PAGE_SIZES)[number] {
|
||||
const parsed = Number(value);
|
||||
return SAVED_KEYWORD_PAGE_SIZES.find((size) => size === parsed) ?? 50;
|
||||
}
|
||||
19
src/client/features/saved-keywords/SavedKeywordsStatus.tsx
Normal file
19
src/client/features/saved-keywords/SavedKeywordsStatus.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function SavedKeywordsStatus({
|
||||
totalCount,
|
||||
isFetching,
|
||||
}: {
|
||||
totalCount: number;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-base-content/60">
|
||||
<span>
|
||||
{totalCount.toLocaleString()} saved keyword
|
||||
{totalCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
{isFetching ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
src/client/features/saved-keywords/SavedKeywordsTable.tsx
Normal file
203
src/client/features/saved-keywords/SavedKeywordsTable.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import {
|
||||
createColumnHelper,
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Search } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
AppDataTable,
|
||||
makeSelectionColumn,
|
||||
useAppTable,
|
||||
useSelectionAnchor,
|
||||
} from "@/client/components/table/AppDataTable";
|
||||
import { SortableHeader } from "@/client/components/table/SortableHeader";
|
||||
import { DifficultyBadge } from "@/client/features/domain/components/DifficultyBadge";
|
||||
import { IntentBadge } from "@/client/features/keywords/components";
|
||||
import type { KeywordIntent, SavedKeywordRow } from "@/types/keywords";
|
||||
import { TagChip } from "./TagChip";
|
||||
import {
|
||||
formatSavedKeywordDate,
|
||||
formatSavedKeywordNumber,
|
||||
} from "./savedKeywordsUtils";
|
||||
|
||||
const columnHelper = createColumnHelper<SavedKeywordRow>();
|
||||
|
||||
export function SavedKeywordsTable({
|
||||
rows,
|
||||
rowSelection,
|
||||
sorting,
|
||||
isLoading,
|
||||
hasActiveFilters,
|
||||
onRowSelectionChange,
|
||||
onSortingChange,
|
||||
}: {
|
||||
rows: SavedKeywordRow[];
|
||||
rowSelection: RowSelectionState;
|
||||
sorting: SortingState;
|
||||
isLoading: boolean;
|
||||
hasActiveFilters: boolean;
|
||||
onRowSelectionChange: OnChangeFn<RowSelectionState>;
|
||||
onSortingChange: OnChangeFn<SortingState>;
|
||||
}) {
|
||||
const selectAnchorRef = useSelectionAnchor();
|
||||
const columns = useMemo<ColumnDef<SavedKeywordRow>[]>(
|
||||
() => [
|
||||
makeSelectionColumn<SavedKeywordRow>(selectAnchorRef),
|
||||
columnHelper.accessor("keyword", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="Keyword" />
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<span className="font-medium">{getValue()}</span>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("searchVolume", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="Volume" />
|
||||
),
|
||||
cell: ({ getValue }) => formatSavedKeywordNumber(getValue()),
|
||||
}),
|
||||
columnHelper.accessor("cpc", {
|
||||
header: ({ column }) => <SortableHeader column={column} label="CPC" />,
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value == null ? "-" : `$${value.toFixed(2)}`;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("competition", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
column={column}
|
||||
label="Competition"
|
||||
helpText="Advertiser competition."
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return value == null ? "-" : value.toFixed(2);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("keywordDifficulty", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader
|
||||
column={column}
|
||||
label="Difficulty"
|
||||
helpText="Keyword difficulty score."
|
||||
/>
|
||||
),
|
||||
cell: ({ getValue }) => <DifficultyBadge value={getValue()} />,
|
||||
}),
|
||||
columnHelper.accessor("intent", {
|
||||
header: () => "Intent",
|
||||
cell: ({ getValue }) => (
|
||||
<IntentBadge intent={normalizeIntent(getValue())} />
|
||||
),
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "tags",
|
||||
header: () => "Tags",
|
||||
cell: ({ row }) => <TagList tags={row.original.tags} />,
|
||||
enableSorting: false,
|
||||
meta: { cellClassName: "min-w-40 max-w-64" },
|
||||
}),
|
||||
columnHelper.accessor("fetchedAt", {
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} label="Last Fetched" />
|
||||
),
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-base-content/55">
|
||||
{formatSavedKeywordDate(getValue())}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
],
|
||||
[selectAnchorRef],
|
||||
);
|
||||
const table = useAppTable({
|
||||
data: rows,
|
||||
columns,
|
||||
state: { rowSelection, sorting },
|
||||
onRowSelectionChange,
|
||||
onSortingChange,
|
||||
getRowId: (row) => row.id,
|
||||
enableRowSelection: true,
|
||||
manualSorting: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<AppDataTable
|
||||
table={table}
|
||||
className="table table-zebra table-sm"
|
||||
isLoading={isLoading}
|
||||
loading={<SavedKeywordsSkeleton />}
|
||||
empty={<SavedKeywordsEmptyState hasActiveFilters={hasActiveFilters} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeIntent(value: string | null): KeywordIntent {
|
||||
switch (value) {
|
||||
case "informational":
|
||||
case "commercial":
|
||||
case "transactional":
|
||||
case "navigational":
|
||||
case "unknown":
|
||||
return value;
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function TagList({ tags }: { tags: SavedKeywordRow["tags"] }) {
|
||||
if (tags.length === 0) {
|
||||
return <span className="text-base-content/35">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<TagChip key={tag.id} tag={tag} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedKeywordsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy>
|
||||
<div className="skeleton h-4 w-48" />
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="grid grid-cols-9 items-center gap-3">
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton col-span-2 h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
<div className="skeleton h-4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedKeywordsEmptyState({
|
||||
hasActiveFilters,
|
||||
}: {
|
||||
hasActiveFilters: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-12 text-center text-sm text-base-content/55">
|
||||
<Search className="mx-auto mb-2 size-8 opacity-40" />
|
||||
<p>
|
||||
{hasActiveFilters
|
||||
? "No saved keywords match the current filters."
|
||||
: "No saved keywords yet. Use the Keyword Research page to find and save keywords."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
src/client/features/saved-keywords/SavedKeywordsTagFilter.tsx
Normal file
318
src/client/features/saved-keywords/SavedKeywordsTagFilter.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Tag as TagIcon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
resolveTagColor,
|
||||
tagDotClass,
|
||||
type TagColorKey,
|
||||
} from "@/shared/tag-colors";
|
||||
import type { SavedKeywordTagSummary } from "@/types/keywords";
|
||||
import { ManageTagRow } from "./ManageTagRow";
|
||||
import { TagChip } from "./TagChip";
|
||||
|
||||
export function SavedKeywordsTagFilter({
|
||||
availableTags,
|
||||
selectedTagIds,
|
||||
onToggleTagFilter,
|
||||
onClearSelection,
|
||||
onUpdateTag,
|
||||
onDeleteTag,
|
||||
busyTagIds,
|
||||
}: {
|
||||
availableTags: SavedKeywordTagSummary[];
|
||||
selectedTagIds: string[];
|
||||
onToggleTagFilter: (tagId: string) => void;
|
||||
onClearSelection: () => void;
|
||||
onUpdateTag: (input: {
|
||||
tagId: string;
|
||||
name?: string;
|
||||
color?: TagColorKey | null;
|
||||
}) => void;
|
||||
onDeleteTag: (tagId: string) => void;
|
||||
busyTagIds: Set<string>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [managingTagId, setManagingTagId] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Node &&
|
||||
containerRef.current &&
|
||||
containerRef.current.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setManagingTagId(null);
|
||||
};
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
setManagingTagId(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const filteredTags = useMemo(() => {
|
||||
const q = query.trim().toLocaleLowerCase();
|
||||
if (!q) return availableTags;
|
||||
return availableTags.filter((tag) => tag.normalizedName.includes(q));
|
||||
}, [availableTags, query]);
|
||||
|
||||
const selectedTags = availableTags.filter((tag) =>
|
||||
selectedTagIds.includes(tag.id),
|
||||
);
|
||||
const hasSelection = selectedTagIds.length > 0;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex h-9 items-center gap-2 rounded-md border px-3 text-sm transition ${
|
||||
hasSelection
|
||||
? "border-primary/50 bg-primary/10 text-base-content"
|
||||
: "border-base-300 bg-base-100 hover:border-base-content/30"
|
||||
}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<TagIcon className="size-3.5 opacity-70" />
|
||||
<span className="font-medium">Tags</span>
|
||||
{hasSelection ? (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold text-primary-content">
|
||||
{selectedTags.length}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronDown className="size-3.5 opacity-60" />
|
||||
</button>
|
||||
|
||||
{selectedTags.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{selectedTags.map((tag) => (
|
||||
<TagChip
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
size="sm"
|
||||
selected
|
||||
onClick={() => onToggleTagFilter(tag.id)}
|
||||
trailing={<X className="size-3 opacity-70" />}
|
||||
title="Remove filter"
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-base-content/60 underline-offset-2 hover:text-base-content hover:underline"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<TagFilterPopover
|
||||
availableTags={availableTags}
|
||||
filteredTags={filteredTags}
|
||||
selectedTagIds={selectedTagIds}
|
||||
query={query}
|
||||
managingTagId={managingTagId}
|
||||
busyTagIds={busyTagIds}
|
||||
onQueryChange={setQuery}
|
||||
onToggleTagFilter={onToggleTagFilter}
|
||||
onStartManaging={setManagingTagId}
|
||||
onUpdateTag={(tagId, input) => {
|
||||
onUpdateTag({ tagId, ...input });
|
||||
setManagingTagId(null);
|
||||
}}
|
||||
onDeleteTag={(tagId) => {
|
||||
onDeleteTag(tagId);
|
||||
setManagingTagId(null);
|
||||
}}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagFilterPopover({
|
||||
availableTags,
|
||||
filteredTags,
|
||||
selectedTagIds,
|
||||
query,
|
||||
managingTagId,
|
||||
busyTagIds,
|
||||
onQueryChange,
|
||||
onToggleTagFilter,
|
||||
onStartManaging,
|
||||
onUpdateTag,
|
||||
onDeleteTag,
|
||||
onClearSelection,
|
||||
}: {
|
||||
availableTags: SavedKeywordTagSummary[];
|
||||
filteredTags: SavedKeywordTagSummary[];
|
||||
selectedTagIds: string[];
|
||||
query: string;
|
||||
managingTagId: string | null;
|
||||
busyTagIds: Set<string>;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleTagFilter: (tagId: string) => void;
|
||||
onStartManaging: (tagId: string | null) => void;
|
||||
onUpdateTag: (
|
||||
tagId: string,
|
||||
input: { name?: string; color?: TagColorKey | null },
|
||||
) => void;
|
||||
onDeleteTag: (tagId: string) => void;
|
||||
onClearSelection: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-0 top-full z-20 mt-2 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-base-300 bg-base-100 shadow-2xl">
|
||||
<div className="border-b border-base-300 p-2">
|
||||
<label className="flex items-center gap-2 rounded-md border border-base-300 bg-base-200/50 px-2 py-1.5">
|
||||
<Search className="size-3.5 opacity-50" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder="Search tags…"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-base-content/40"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-base-content/40 hover:text-base-content"
|
||||
onClick={() => onQueryChange("")}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 overflow-y-auto py-1">
|
||||
{filteredTags.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-base-content/55">
|
||||
{availableTags.length === 0
|
||||
? "No tags yet. Add tags from a selection of keywords."
|
||||
: "No tags match that search."}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filteredTags.map((tag) => (
|
||||
<TagFilterRow
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
checked={selectedTagIds.includes(tag.id)}
|
||||
isManaging={managingTagId === tag.id}
|
||||
isBusy={busyTagIds.has(tag.id)}
|
||||
onToggle={() => onToggleTagFilter(tag.id)}
|
||||
onStartManaging={onStartManaging}
|
||||
onUpdate={(input) => onUpdateTag(tag.id, input)}
|
||||
onDelete={() => onDeleteTag(tag.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTagIds.length > 0 ? (
|
||||
<div className="flex items-center justify-between border-t border-base-300 px-2 py-1.5 text-xs">
|
||||
<span className="text-base-content/55">
|
||||
{selectedTagIds.length} selected
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-base-content/70 hover:bg-base-200"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagFilterRow({
|
||||
tag,
|
||||
checked,
|
||||
isManaging,
|
||||
isBusy,
|
||||
onToggle,
|
||||
onStartManaging,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
tag: SavedKeywordTagSummary;
|
||||
checked: boolean;
|
||||
isManaging: boolean;
|
||||
isBusy: boolean;
|
||||
onToggle: () => void;
|
||||
onStartManaging: (tagId: string | null) => void;
|
||||
onUpdate: (input: { name?: string; color?: TagColorKey | null }) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const color = resolveTagColor(tag);
|
||||
return (
|
||||
<div>
|
||||
<div className="group flex items-center gap-2 px-2 py-1.5 hover:bg-base-200">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span
|
||||
className={`flex size-4 shrink-0 items-center justify-center rounded border ${
|
||||
checked
|
||||
? "border-primary bg-primary text-primary-content"
|
||||
: "border-base-300"
|
||||
}`}
|
||||
>
|
||||
{checked ? <Check className="size-3" /> : null}
|
||||
</span>
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full ${tagDotClass(color)}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{tag.name}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-base-content/45">
|
||||
{tag.keywordCount}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded p-1 text-base-content/45 hover:bg-base-300 hover:text-base-content ${
|
||||
isManaging ? "bg-base-300 text-base-content" : ""
|
||||
}`}
|
||||
onClick={() => onStartManaging(isManaging ? null : tag.id)}
|
||||
aria-label={`Manage ${tag.name}`}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isManaging ? (
|
||||
<ManageTagRow
|
||||
tag={tag}
|
||||
isBusy={isBusy}
|
||||
onSave={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onCancel={() => onStartManaging(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/client/features/saved-keywords/TagChip.tsx
Normal file
66
src/client/features/saved-keywords/TagChip.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
resolveTagColor,
|
||||
tagChipClass,
|
||||
tagDotClass,
|
||||
} from "@/shared/tag-colors";
|
||||
import type { SavedKeywordTag } from "@/types/keywords";
|
||||
|
||||
type Size = "xs" | "sm" | "md";
|
||||
|
||||
const SIZE_CLASS: Record<Size, string> = {
|
||||
xs: "h-5 px-1.5 text-[11px]",
|
||||
sm: "h-6 px-2 text-xs",
|
||||
md: "h-7 px-2.5 text-sm",
|
||||
};
|
||||
|
||||
export function TagChip({
|
||||
tag,
|
||||
size = "sm",
|
||||
trailing,
|
||||
onClick,
|
||||
selected,
|
||||
title,
|
||||
}: {
|
||||
tag: Pick<SavedKeywordTag, "id" | "name" | "color">;
|
||||
size?: Size;
|
||||
trailing?: ReactNode;
|
||||
onClick?: () => void;
|
||||
selected?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
const color = resolveTagColor(tag);
|
||||
const base = `inline-flex items-center gap-1.5 rounded-md font-medium ${SIZE_CLASS[size]} ${tagChipClass(color)}`;
|
||||
const interactive = onClick
|
||||
? "cursor-pointer hover:brightness-110 transition"
|
||||
: "";
|
||||
const ring = selected ? "ring-2 ring-offset-1 ring-offset-base-100" : "";
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${tagDotClass(color)}`}
|
||||
/>
|
||||
<span className="truncate">{tag.name}</span>
|
||||
{trailing}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
className={`${base} ${interactive} ${ring}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span title={title} className={`${base} ${ring}`}>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
export type SavedKeywordsFilterValues = {
|
||||
include: string;
|
||||
exclude: string;
|
||||
minVol: string;
|
||||
maxVol: string;
|
||||
minCpc: string;
|
||||
maxCpc: string;
|
||||
minKd: string;
|
||||
maxKd: string;
|
||||
};
|
||||
|
||||
export const EMPTY_SAVED_KEYWORDS_FILTERS: SavedKeywordsFilterValues = {
|
||||
include: "",
|
||||
exclude: "",
|
||||
minVol: "",
|
||||
maxVol: "",
|
||||
minCpc: "",
|
||||
maxCpc: "",
|
||||
minKd: "",
|
||||
maxKd: "",
|
||||
};
|
||||
|
||||
export type AppliedSavedKeywordsFilters = {
|
||||
includeTerms?: string[];
|
||||
excludeTerms?: string[];
|
||||
minVolume?: number | null;
|
||||
maxVolume?: number | null;
|
||||
minCpc?: number | null;
|
||||
maxCpc?: number | null;
|
||||
minDifficulty?: number | null;
|
||||
maxDifficulty?: number | null;
|
||||
};
|
||||
|
||||
function parseTerms(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[,+]/)
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function clamp(value: number, bounds: { min?: number; max?: number }) {
|
||||
if (bounds.min != null && value < bounds.min) return bounds.min;
|
||||
if (bounds.max != null && value > bounds.max) return bounds.max;
|
||||
return value;
|
||||
}
|
||||
|
||||
function toIntOrUndef(
|
||||
value: string,
|
||||
bounds: { min?: number; max?: number } = {},
|
||||
): number | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return Math.trunc(clamp(n, bounds));
|
||||
}
|
||||
|
||||
function toFloatOrUndef(
|
||||
value: string,
|
||||
bounds: { min?: number; max?: number } = {},
|
||||
): number | undefined {
|
||||
if (!value.trim()) return undefined;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return clamp(n, bounds);
|
||||
}
|
||||
|
||||
export function compileSavedKeywordsFilters(
|
||||
values: SavedKeywordsFilterValues,
|
||||
): AppliedSavedKeywordsFilters {
|
||||
const includeTerms = parseTerms(values.include);
|
||||
const excludeTerms = parseTerms(values.exclude);
|
||||
return {
|
||||
includeTerms: includeTerms.length > 0 ? includeTerms : undefined,
|
||||
excludeTerms: excludeTerms.length > 0 ? excludeTerms : undefined,
|
||||
minVolume: toIntOrUndef(values.minVol, { min: 0 }),
|
||||
maxVolume: toIntOrUndef(values.maxVol, { min: 0 }),
|
||||
minCpc: toFloatOrUndef(values.minCpc, { min: 0 }),
|
||||
maxCpc: toFloatOrUndef(values.maxCpc, { min: 0 }),
|
||||
minDifficulty: toIntOrUndef(values.minKd, { min: 0, max: 100 }),
|
||||
maxDifficulty: toIntOrUndef(values.maxKd, { min: 0, max: 100 }),
|
||||
};
|
||||
}
|
||||
|
||||
export function countActiveSavedKeywordsFilters(
|
||||
values: SavedKeywordsFilterValues,
|
||||
): number {
|
||||
return Object.values(values).filter((value) => value.trim() !== "").length;
|
||||
}
|
||||
50
src/client/features/saved-keywords/savedKeywordsUtils.ts
Normal file
50
src/client/features/saved-keywords/savedKeywordsUtils.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { CsvValue } from "@/client/lib/csv";
|
||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
||||
import type { SavedKeywordRow } from "@/types/keywords";
|
||||
import type { GetSavedKeywordsInput } from "@/types/schemas/keywords";
|
||||
|
||||
export const SAVED_KEYWORD_PAGE_SIZES = [50, 100, 250] as const;
|
||||
export const SAVED_KEYWORD_EXPORT_HEADERS = [
|
||||
...KEYWORD_RESEARCH_HEADERS,
|
||||
"Tags",
|
||||
"Fetched At",
|
||||
];
|
||||
|
||||
export function savedKeywordExportRow(row: SavedKeywordRow): CsvValue[] {
|
||||
return [
|
||||
row.keyword,
|
||||
row.searchVolume ?? "",
|
||||
row.cpc ?? "",
|
||||
row.competition ?? "",
|
||||
row.keywordDifficulty ?? "",
|
||||
row.intent ?? "",
|
||||
row.tags.map((tag) => tag.name).join(", "),
|
||||
row.fetchedAt ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
export function toSavedKeywordSort(
|
||||
value: string | undefined,
|
||||
): GetSavedKeywordsInput["sort"] {
|
||||
if (
|
||||
value === "keyword" ||
|
||||
value === "searchVolume" ||
|
||||
value === "cpc" ||
|
||||
value === "competition" ||
|
||||
value === "keywordDifficulty" ||
|
||||
value === "fetchedAt"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return "createdAt";
|
||||
}
|
||||
|
||||
export function formatSavedKeywordNumber(value: number | null | undefined) {
|
||||
if (value == null) return "-";
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
export function formatSavedKeywordDate(value: string | null | undefined) {
|
||||
if (!value) return "-";
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
142
src/client/features/saved-keywords/useSavedKeywordsExport.ts
Normal file
142
src/client/features/saved-keywords/useSavedKeywordsExport.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { exportSavedKeywords } from "@/serverFunctions/keywords";
|
||||
import type { SavedKeywordRow } from "@/types/keywords";
|
||||
import type { ExportSavedKeywordsInput } from "@/types/schemas/keywords";
|
||||
import type { AppliedSavedKeywordsFilters } from "./savedKeywordsFilterTypes";
|
||||
import {
|
||||
SAVED_KEYWORD_EXPORT_HEADERS,
|
||||
savedKeywordExportRow,
|
||||
} from "./savedKeywordsUtils";
|
||||
|
||||
export function useSavedKeywordsExport(params: {
|
||||
projectId: string;
|
||||
appliedFilters: AppliedSavedKeywordsFilters;
|
||||
selectedTagIds: string[];
|
||||
sort: ExportSavedKeywordsInput["sort"];
|
||||
order: ExportSavedKeywordsInput["order"];
|
||||
}) {
|
||||
const [exporting, setExporting] = useState<"csv" | "sheets" | null>(null);
|
||||
const [exportingSelection, setExportingSelection] = useState<
|
||||
"csv" | "sheets" | null
|
||||
>(null);
|
||||
|
||||
const exportInput = useMemo<ExportSavedKeywordsInput>(
|
||||
() => ({
|
||||
projectId: params.projectId,
|
||||
...params.appliedFilters,
|
||||
tagIds:
|
||||
params.selectedTagIds.length > 0 ? params.selectedTagIds : undefined,
|
||||
sort: params.sort,
|
||||
order: params.order,
|
||||
}),
|
||||
[
|
||||
params.appliedFilters,
|
||||
params.order,
|
||||
params.projectId,
|
||||
params.selectedTagIds,
|
||||
params.sort,
|
||||
],
|
||||
);
|
||||
|
||||
const loadFilteredRows = async () => {
|
||||
const result = await exportSavedKeywords({ data: exportInput });
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const exportFilteredCsv = async () => {
|
||||
setExporting("csv");
|
||||
try {
|
||||
const rows = await loadFilteredRows();
|
||||
if (rows.length === 0) {
|
||||
toast.error("No keywords to export");
|
||||
return;
|
||||
}
|
||||
downloadKeywordCsv(rows);
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "saved_keywords",
|
||||
result_count: rows.length,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(getStandardErrorMessage(error, "Could not export CSV"));
|
||||
} finally {
|
||||
setExporting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const exportFilteredSheets = async () => {
|
||||
setExporting("sheets");
|
||||
try {
|
||||
const rows = await loadFilteredRows();
|
||||
await exportTableToSheets({
|
||||
headers: SAVED_KEYWORD_EXPORT_HEADERS,
|
||||
rows: rows.map(savedKeywordExportRow),
|
||||
feature: "saved_keywords",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(getStandardErrorMessage(error, "Could not export to Sheets"));
|
||||
} finally {
|
||||
setExporting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const exportSelectionCsv = (selectedRows: SavedKeywordRow[]) => {
|
||||
if (selectedRows.length === 0) return;
|
||||
setExportingSelection("csv");
|
||||
try {
|
||||
downloadKeywordCsv(selectedRows);
|
||||
captureClientEvent("data:export", {
|
||||
source_feature: "saved_keywords",
|
||||
result_count: selectedRows.length,
|
||||
scope: "selection",
|
||||
});
|
||||
} finally {
|
||||
setExportingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
const exportSelectionSheets = async (selectedRows: SavedKeywordRow[]) => {
|
||||
if (selectedRows.length === 0) return;
|
||||
setExportingSelection("sheets");
|
||||
try {
|
||||
await exportTableToSheets({
|
||||
headers: SAVED_KEYWORD_EXPORT_HEADERS,
|
||||
rows: selectedRows.map(savedKeywordExportRow),
|
||||
feature: "saved_keywords",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(getStandardErrorMessage(error, "Could not export to Sheets"));
|
||||
} finally {
|
||||
setExportingSelection(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
exporting,
|
||||
exportingSelection,
|
||||
exportFilteredCsv,
|
||||
exportFilteredSheets,
|
||||
exportSelectionCsv,
|
||||
exportSelectionSheets,
|
||||
};
|
||||
}
|
||||
|
||||
function downloadKeywordCsv(rows: SavedKeywordRow[]) {
|
||||
const csvRows = rows
|
||||
.map(savedKeywordExportRow)
|
||||
.map((row) =>
|
||||
row.map((cell, index) =>
|
||||
(index === 2 || index === 3) && typeof cell === "number"
|
||||
? cell.toFixed(2)
|
||||
: cell,
|
||||
),
|
||||
);
|
||||
downloadCsv(
|
||||
"saved-keywords.csv",
|
||||
buildCsv(SAVED_KEYWORD_EXPORT_HEADERS, csvRows),
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import { useForm, useStore } from "@tanstack/react-form";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
countActiveSavedKeywordsFilters,
|
||||
EMPTY_SAVED_KEYWORDS_FILTERS,
|
||||
type SavedKeywordsFilterValues,
|
||||
} from "./savedKeywordsFilterTypes";
|
||||
|
||||
const FILTER_KEYS: Array<keyof SavedKeywordsFilterValues> = [
|
||||
"include",
|
||||
"exclude",
|
||||
"minVol",
|
||||
"maxVol",
|
||||
"minCpc",
|
||||
"maxCpc",
|
||||
"minKd",
|
||||
"maxKd",
|
||||
];
|
||||
|
||||
export function useSavedKeywordsFilters() {
|
||||
const filtersForm = useForm({ defaultValues: EMPTY_SAVED_KEYWORDS_FILTERS });
|
||||
const values = useStore(filtersForm.store, (s) => s.values);
|
||||
const activeFilterCount = countActiveSavedKeywordsFilters(values);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
for (const key of FILTER_KEYS) {
|
||||
filtersForm.setFieldValue(key, "");
|
||||
}
|
||||
}, [filtersForm]);
|
||||
|
||||
return { filtersForm, values, activeFilterCount, resetFilters };
|
||||
}
|
||||
|
||||
export type SavedKeywordsFilterForm = ReturnType<
|
||||
typeof useSavedKeywordsFilters
|
||||
>["filtersForm"];
|
||||
72
src/client/features/saved-keywords/useTagManage.ts
Normal file
72
src/client/features/saved-keywords/useTagManage.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import {
|
||||
deleteSavedKeywordTag,
|
||||
updateSavedKeywordTag,
|
||||
} from "@/serverFunctions/keywords";
|
||||
import type { TagColorKey } from "@/shared/tag-colors";
|
||||
|
||||
export function useTagManage(projectId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const [busyTagIds, setBusyTagIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const markBusy = (tagId: string, busy: boolean) => {
|
||||
setBusyTagIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (busy) next.add(tagId);
|
||||
else next.delete(tagId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["savedKeywords", projectId] });
|
||||
|
||||
const updateTag = async (input: {
|
||||
tagId: string;
|
||||
name?: string;
|
||||
color?: TagColorKey | null;
|
||||
}) => {
|
||||
markBusy(input.tagId, true);
|
||||
try {
|
||||
await updateSavedKeywordTag({
|
||||
data: {
|
||||
projectId,
|
||||
tagId: input.tagId,
|
||||
name: input.name,
|
||||
color: input.color ?? undefined,
|
||||
},
|
||||
});
|
||||
await invalidate();
|
||||
toast.success("Tag updated");
|
||||
} catch (error) {
|
||||
toast.error(getStandardErrorMessage(error, "Could not update tag"));
|
||||
} finally {
|
||||
markBusy(input.tagId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTag = async (tagId: string): Promise<boolean> => {
|
||||
markBusy(tagId, true);
|
||||
try {
|
||||
await deleteSavedKeywordTag({ data: { projectId, tagId } });
|
||||
await invalidate();
|
||||
toast.success("Tag deleted");
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getStandardErrorMessage(
|
||||
error,
|
||||
"Could not delete tag. Detach it from all keywords and try again.",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
markBusy(tagId, false);
|
||||
}
|
||||
};
|
||||
|
||||
return { busyTagIds, updateTag, deleteTag };
|
||||
}
|
||||
@ -169,34 +169,141 @@ select {
|
||||
@apply border-error/70 bg-error/40 text-base-content/90;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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'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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,413 @@
|
||||
import { and, asc, count, eq, inArray, notInArray } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
savedKeywordTagAssignments,
|
||||
savedKeywordTags,
|
||||
savedKeywords,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
normalizeSavedKeywordTag,
|
||||
normalizeSavedKeywordTags,
|
||||
} from "@/shared/saved-keyword-tags";
|
||||
|
||||
export type SavedKeywordTagRecord = typeof savedKeywordTags.$inferSelect;
|
||||
|
||||
const QUERY_CHUNK_SIZE = 80;
|
||||
const DELETE_PAIR_CHUNK_SIZE = 45;
|
||||
const ASSIGNMENT_INSERT_CHUNK_SIZE = 40;
|
||||
const REPLACE_DELETE_KEYWORD_CHUNK_SIZE = 70;
|
||||
|
||||
async function getTagFilterIds(params: {
|
||||
projectId: string;
|
||||
tagIds?: string[];
|
||||
tagNames?: string[];
|
||||
}): Promise<{ tagIds: string[]; emptyTagNameMatch: boolean }> {
|
||||
const directTagIds = params.tagIds ?? [];
|
||||
const normalizedTags = normalizeSavedKeywordTags(params.tagNames);
|
||||
if (normalizedTags.length === 0) {
|
||||
return { tagIds: [...new Set(directTagIds)], emptyTagNameMatch: false };
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ id: savedKeywordTags.id })
|
||||
.from(savedKeywordTags)
|
||||
.where(
|
||||
and(
|
||||
eq(savedKeywordTags.projectId, params.projectId),
|
||||
inArray(
|
||||
savedKeywordTags.normalizedName,
|
||||
normalizedTags.map((tag) => tag.normalizedName),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
tagIds: [...new Set([...directTagIds, ...rows.map((row) => row.id)])],
|
||||
emptyTagNameMatch: directTagIds.length === 0 && rows.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSavedKeywordTagsByProject(projectId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: savedKeywordTags.id,
|
||||
projectId: savedKeywordTags.projectId,
|
||||
name: savedKeywordTags.name,
|
||||
normalizedName: savedKeywordTags.normalizedName,
|
||||
color: savedKeywordTags.color,
|
||||
createdAt: savedKeywordTags.createdAt,
|
||||
keywordCount: count(savedKeywordTagAssignments.savedKeywordId),
|
||||
})
|
||||
.from(savedKeywordTags)
|
||||
.leftJoin(
|
||||
savedKeywordTagAssignments,
|
||||
eq(savedKeywordTagAssignments.tagId, savedKeywordTags.id),
|
||||
)
|
||||
.where(eq(savedKeywordTags.projectId, projectId))
|
||||
.groupBy(
|
||||
savedKeywordTags.id,
|
||||
savedKeywordTags.projectId,
|
||||
savedKeywordTags.name,
|
||||
savedKeywordTags.normalizedName,
|
||||
savedKeywordTags.color,
|
||||
savedKeywordTags.createdAt,
|
||||
)
|
||||
.orderBy(asc(savedKeywordTags.normalizedName));
|
||||
}
|
||||
|
||||
async function listTagsBySavedKeywordIds(
|
||||
projectId: string,
|
||||
savedKeywordIds: string[],
|
||||
) {
|
||||
const tagsByKeywordId = new Map<string, SavedKeywordTagRecord[]>();
|
||||
if (savedKeywordIds.length === 0) return tagsByKeywordId;
|
||||
|
||||
for (let i = 0; i < savedKeywordIds.length; i += QUERY_CHUNK_SIZE) {
|
||||
const chunk = savedKeywordIds.slice(i, i + QUERY_CHUNK_SIZE);
|
||||
const rows = await db
|
||||
.select({
|
||||
savedKeywordId: savedKeywordTagAssignments.savedKeywordId,
|
||||
tag: savedKeywordTags,
|
||||
})
|
||||
.from(savedKeywordTagAssignments)
|
||||
.innerJoin(
|
||||
savedKeywordTags,
|
||||
eq(savedKeywordTags.id, savedKeywordTagAssignments.tagId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(savedKeywordTags.projectId, projectId),
|
||||
inArray(savedKeywordTagAssignments.savedKeywordId, chunk),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(savedKeywordTags.normalizedName));
|
||||
|
||||
for (const { savedKeywordId, tag } of rows) {
|
||||
const tags = tagsByKeywordId.get(savedKeywordId) ?? [];
|
||||
tags.push(tag);
|
||||
tagsByKeywordId.set(savedKeywordId, tags);
|
||||
}
|
||||
}
|
||||
|
||||
return tagsByKeywordId;
|
||||
}
|
||||
|
||||
async function addTagsToSavedKeywords(params: {
|
||||
projectId: string;
|
||||
savedKeywordIds: string[];
|
||||
tagNames: string[];
|
||||
}) {
|
||||
const savedKeywordRows = await listSavedKeywordRowsByIds(
|
||||
params.projectId,
|
||||
params.savedKeywordIds,
|
||||
);
|
||||
if (savedKeywordRows.length === 0) {
|
||||
return { savedKeywordCount: 0, tags: [] };
|
||||
}
|
||||
|
||||
const tags = await upsertSavedKeywordTags(params.projectId, params.tagNames);
|
||||
|
||||
const assignments = savedKeywordRows.flatMap((row) =>
|
||||
tags.map((tag) => ({ savedKeywordId: row.id, tagId: tag.id })),
|
||||
);
|
||||
for (let i = 0; i < assignments.length; i += ASSIGNMENT_INSERT_CHUNK_SIZE) {
|
||||
const chunk = assignments.slice(i, i + ASSIGNMENT_INSERT_CHUNK_SIZE);
|
||||
await db
|
||||
.insert(savedKeywordTagAssignments)
|
||||
.values(chunk)
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
return { savedKeywordCount: savedKeywordRows.length, tags };
|
||||
}
|
||||
|
||||
async function replaceTagsForSavedKeywords(params: {
|
||||
projectId: string;
|
||||
savedKeywordIds: string[];
|
||||
tagNames: string[];
|
||||
}) {
|
||||
const addResult = await addTagsToSavedKeywords(params);
|
||||
const keepTagIds = addResult.tags.map((tag) => tag.id);
|
||||
if (addResult.savedKeywordCount === 0 || keepTagIds.length === 0) {
|
||||
return { ...addResult, removedCount: 0 };
|
||||
}
|
||||
|
||||
let removedCount = 0;
|
||||
for (
|
||||
let i = 0;
|
||||
i < params.savedKeywordIds.length;
|
||||
i += REPLACE_DELETE_KEYWORD_CHUNK_SIZE
|
||||
) {
|
||||
const chunk = params.savedKeywordIds.slice(
|
||||
i,
|
||||
i + REPLACE_DELETE_KEYWORD_CHUNK_SIZE,
|
||||
);
|
||||
const savedKeywordRows = await listSavedKeywordRowsByIds(
|
||||
params.projectId,
|
||||
chunk,
|
||||
);
|
||||
const savedKeywordIds = savedKeywordRows.map((row) => row.id);
|
||||
if (savedKeywordIds.length === 0) continue;
|
||||
|
||||
const deleted = await db
|
||||
.delete(savedKeywordTagAssignments)
|
||||
.where(
|
||||
and(
|
||||
inArray(savedKeywordTagAssignments.savedKeywordId, savedKeywordIds),
|
||||
notInArray(savedKeywordTagAssignments.tagId, keepTagIds),
|
||||
),
|
||||
)
|
||||
.returning({ id: savedKeywordTagAssignments.savedKeywordId });
|
||||
removedCount += deleted.length;
|
||||
}
|
||||
|
||||
return { ...addResult, removedCount };
|
||||
}
|
||||
|
||||
async function removeTagsFromSavedKeywords(params: {
|
||||
projectId: string;
|
||||
savedKeywordIds: string[];
|
||||
tagIds: string[];
|
||||
}) {
|
||||
const [savedKeywordRows, tags] = await Promise.all([
|
||||
listSavedKeywordRowsByIds(params.projectId, params.savedKeywordIds),
|
||||
listSavedKeywordTagsByIds(params.projectId, params.tagIds),
|
||||
]);
|
||||
const savedKeywordIds = savedKeywordRows.map((row) => row.id);
|
||||
const tagIds = tags.map((tag) => tag.id);
|
||||
let removedCount = 0;
|
||||
|
||||
for (let i = 0; i < savedKeywordIds.length; i += DELETE_PAIR_CHUNK_SIZE) {
|
||||
const savedKeywordChunk = savedKeywordIds.slice(
|
||||
i,
|
||||
i + DELETE_PAIR_CHUNK_SIZE,
|
||||
);
|
||||
for (let j = 0; j < tagIds.length; j += DELETE_PAIR_CHUNK_SIZE) {
|
||||
const tagChunk = tagIds.slice(j, j + DELETE_PAIR_CHUNK_SIZE);
|
||||
const deleted = await db
|
||||
.delete(savedKeywordTagAssignments)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
savedKeywordTagAssignments.savedKeywordId,
|
||||
savedKeywordChunk,
|
||||
),
|
||||
inArray(savedKeywordTagAssignments.tagId, tagChunk),
|
||||
),
|
||||
)
|
||||
.returning({ tagId: savedKeywordTagAssignments.tagId });
|
||||
removedCount += deleted.length;
|
||||
}
|
||||
}
|
||||
|
||||
return { removedCount, savedKeywordCount: savedKeywordRows.length, tags };
|
||||
}
|
||||
|
||||
async function removeAllTagsFromSavedKeywords(params: {
|
||||
projectId: string;
|
||||
savedKeywordIds: string[];
|
||||
}) {
|
||||
const savedKeywordRows = await listSavedKeywordRowsByIds(
|
||||
params.projectId,
|
||||
params.savedKeywordIds,
|
||||
);
|
||||
let removedCount = 0;
|
||||
|
||||
for (let i = 0; i < savedKeywordRows.length; i += QUERY_CHUNK_SIZE) {
|
||||
const chunk = savedKeywordRows.slice(i, i + QUERY_CHUNK_SIZE);
|
||||
const deleted = await db
|
||||
.delete(savedKeywordTagAssignments)
|
||||
.where(
|
||||
inArray(
|
||||
savedKeywordTagAssignments.savedKeywordId,
|
||||
chunk.map((row) => row.id),
|
||||
),
|
||||
)
|
||||
.returning({ id: savedKeywordTagAssignments.savedKeywordId });
|
||||
removedCount += deleted.length;
|
||||
}
|
||||
|
||||
return { removedCount, savedKeywordCount: savedKeywordRows.length };
|
||||
}
|
||||
|
||||
async function upsertSavedKeywordTags(
|
||||
projectId: string,
|
||||
tagNames: readonly string[] | undefined,
|
||||
) {
|
||||
const normalizedTags = normalizeSavedKeywordTags(tagNames);
|
||||
if (normalizedTags.length === 0) return [];
|
||||
|
||||
const [first, ...rest] = normalizedTags.map((tag) =>
|
||||
db
|
||||
.insert(savedKeywordTags)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
name: tag.name,
|
||||
normalizedName: tag.normalizedName,
|
||||
})
|
||||
.onConflictDoNothing(),
|
||||
);
|
||||
await db.batch([first, ...rest]);
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(savedKeywordTags)
|
||||
.where(
|
||||
and(
|
||||
eq(savedKeywordTags.projectId, projectId),
|
||||
inArray(
|
||||
savedKeywordTags.normalizedName,
|
||||
normalizedTags.map((tag) => tag.normalizedName),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(savedKeywordTags.normalizedName));
|
||||
}
|
||||
|
||||
async function listSavedKeywordRowsByIds(
|
||||
projectId: string,
|
||||
savedKeywordIds: string[],
|
||||
) {
|
||||
const rows: (typeof savedKeywords.$inferSelect)[] = [];
|
||||
for (let i = 0; i < savedKeywordIds.length; i += QUERY_CHUNK_SIZE) {
|
||||
const chunk = savedKeywordIds.slice(i, i + QUERY_CHUNK_SIZE);
|
||||
rows.push(
|
||||
...(await db
|
||||
.select()
|
||||
.from(savedKeywords)
|
||||
.where(
|
||||
and(
|
||||
eq(savedKeywords.projectId, projectId),
|
||||
inArray(savedKeywords.id, chunk),
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listSavedKeywordTagsByIds(projectId: string, tagIds: string[]) {
|
||||
if (tagIds.length === 0) return [];
|
||||
const rows: SavedKeywordTagRecord[] = [];
|
||||
for (let i = 0; i < tagIds.length; i += QUERY_CHUNK_SIZE) {
|
||||
const chunk = tagIds.slice(i, i + QUERY_CHUNK_SIZE);
|
||||
rows.push(
|
||||
...(await db
|
||||
.select()
|
||||
.from(savedKeywordTags)
|
||||
.where(
|
||||
and(
|
||||
eq(savedKeywordTags.projectId, projectId),
|
||||
inArray(savedKeywordTags.id, chunk),
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function updateSavedKeywordTag(params: {
|
||||
projectId: string;
|
||||
tagId: string;
|
||||
name?: string;
|
||||
color?: string | null;
|
||||
}) {
|
||||
const updates: Partial<typeof savedKeywordTags.$inferInsert> = {};
|
||||
if (params.name !== undefined) {
|
||||
const normalizedTag = normalizeSavedKeywordTag(params.name);
|
||||
if (!normalizedTag) return null;
|
||||
updates.name = normalizedTag.name;
|
||||
updates.normalizedName = normalizedTag.normalizedName;
|
||||
}
|
||||
if (params.color !== undefined) {
|
||||
updates.color = params.color;
|
||||
}
|
||||
if (Object.keys(updates).length === 0) return null;
|
||||
|
||||
const [updated] = await db
|
||||
.update(savedKeywordTags)
|
||||
.set(updates)
|
||||
.where(
|
||||
and(
|
||||
eq(savedKeywordTags.projectId, params.projectId),
|
||||
eq(savedKeywordTags.id, params.tagId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async function deleteSavedKeywordTag(params: {
|
||||
projectId: string;
|
||||
tagId: string;
|
||||
}): Promise<
|
||||
| { status: "deleted" }
|
||||
| { status: "not_found" }
|
||||
| { status: "in_use"; assignmentCount: number }
|
||||
> {
|
||||
const [tag] = await db
|
||||
.select({ id: savedKeywordTags.id })
|
||||
.from(savedKeywordTags)
|
||||
.where(
|
||||
and(
|
||||
eq(savedKeywordTags.projectId, params.projectId),
|
||||
eq(savedKeywordTags.id, params.tagId),
|
||||
),
|
||||
);
|
||||
if (!tag) return { status: "not_found" };
|
||||
|
||||
// Guard: refuse to delete a tag that's still attached to saved keywords.
|
||||
// FK cascade would otherwise silently drop assignments.
|
||||
const [{ value: assignmentCount } = { value: 0 }] = await db
|
||||
.select({ value: count() })
|
||||
.from(savedKeywordTagAssignments)
|
||||
.where(eq(savedKeywordTagAssignments.tagId, params.tagId));
|
||||
|
||||
if (assignmentCount > 0) {
|
||||
return { status: "in_use", assignmentCount };
|
||||
}
|
||||
|
||||
const deleted = await db
|
||||
.delete(savedKeywordTags)
|
||||
.where(
|
||||
and(
|
||||
eq(savedKeywordTags.projectId, params.projectId),
|
||||
eq(savedKeywordTags.id, params.tagId),
|
||||
),
|
||||
)
|
||||
.returning({ id: savedKeywordTags.id });
|
||||
return deleted.length > 0 ? { status: "deleted" } : { status: "not_found" };
|
||||
}
|
||||
|
||||
export const SavedKeywordTagsRepository = {
|
||||
getTagFilterIds,
|
||||
listSavedKeywordTagsByProject,
|
||||
listTagsBySavedKeywordIds,
|
||||
addTagsToSavedKeywords,
|
||||
replaceTagsForSavedKeywords,
|
||||
removeTagsFromSavedKeywords,
|
||||
removeAllTagsFromSavedKeywords,
|
||||
updateSavedKeywordTag,
|
||||
deleteSavedKeywordTag,
|
||||
} as const;
|
||||
@ -1,9 +1,13 @@
|
||||
import {
|
||||
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;
|
||||
|
||||
@ -3,5 +3,9 @@ export { getSerpAnalysis } from "./serp";
|
||||
export {
|
||||
saveKeywords,
|
||||
getSavedKeywords,
|
||||
exportSavedKeywords,
|
||||
updateSavedKeywordTags,
|
||||
updateSavedKeywordTag,
|
||||
deleteSavedKeywordTag,
|
||||
removeSavedKeywords,
|
||||
} from "./saved-keywords";
|
||||
|
||||
@ -0,0 +1,248 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
addTagsToSavedKeywords: vi.fn(),
|
||||
listSavedKeywordsByProject: vi.fn(),
|
||||
removeAllTagsFromSavedKeywords: vi.fn(),
|
||||
removeSavedKeywords: vi.fn(),
|
||||
removeTagsFromSavedKeywords: vi.fn(),
|
||||
replaceTagsForSavedKeywords: vi.fn(),
|
||||
saveKeywordsToProject: vi.fn(),
|
||||
upsertKeywordMetric: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/server/features/keywords/repositories/KeywordResearchRepository",
|
||||
() => ({
|
||||
KeywordResearchRepository: mocks,
|
||||
}),
|
||||
);
|
||||
|
||||
const savedKeywordRow = {
|
||||
id: "saved_1",
|
||||
projectId: "project_1",
|
||||
keyword: "technical seo",
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("saved keyword service", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
});
|
||||
|
||||
it("attaches tags to saved keyword rows after saving", async () => {
|
||||
mocks.saveKeywordsToProject.mockResolvedValue([
|
||||
savedKeywordRow,
|
||||
{ ...savedKeywordRow, id: "saved_2", keyword: "content seo" },
|
||||
]);
|
||||
mocks.addTagsToSavedKeywords.mockResolvedValue({
|
||||
savedKeywordCount: 2,
|
||||
tags: [],
|
||||
});
|
||||
const { saveKeywords } = await import("./saved-keywords");
|
||||
|
||||
await saveKeywords({
|
||||
projectId: "project_1",
|
||||
keywords: [" Technical SEO ", "technical seo", "Content SEO"],
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
tagMode: "append",
|
||||
tags: ["Content", "BOFU"],
|
||||
});
|
||||
|
||||
expect(mocks.saveKeywordsToProject).toHaveBeenCalledWith({
|
||||
projectId: "project_1",
|
||||
keywords: ["technical seo", "content seo"],
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
});
|
||||
expect(mocks.addTagsToSavedKeywords).toHaveBeenCalledWith({
|
||||
projectId: "project_1",
|
||||
savedKeywordIds: ["saved_1", "saved_2"],
|
||||
tagNames: ["Content", "BOFU"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call tag assignment when no tags are provided", async () => {
|
||||
mocks.saveKeywordsToProject.mockResolvedValue([savedKeywordRow]);
|
||||
const { saveKeywords } = await import("./saved-keywords");
|
||||
|
||||
await saveKeywords({
|
||||
projectId: "project_1",
|
||||
keywords: ["technical seo"],
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
tagMode: "append",
|
||||
});
|
||||
|
||||
expect(mocks.addTagsToSavedKeywords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps paged saved keyword rows with attached tags", async () => {
|
||||
mocks.listSavedKeywordsByProject.mockResolvedValue({
|
||||
totalCount: 1,
|
||||
tags: [
|
||||
{
|
||||
id: "tag_1",
|
||||
projectId: "project_1",
|
||||
name: "Content",
|
||||
normalizedName: "content",
|
||||
color: null,
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
keywordCount: 1,
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
row: savedKeywordRow,
|
||||
metric: {
|
||||
id: 1,
|
||||
projectId: "project_1",
|
||||
keyword: "technical seo",
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
searchVolume: 120,
|
||||
cpc: 2.5,
|
||||
competition: 0.2,
|
||||
keywordDifficulty: 18,
|
||||
intent: "informational",
|
||||
monthlySearches: null,
|
||||
fetchedAt: "2026-05-10T00:00:00.000Z",
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
id: "tag_1",
|
||||
projectId: "project_1",
|
||||
name: "Content",
|
||||
normalizedName: "content",
|
||||
color: null,
|
||||
createdAt: "2026-05-11T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const { getSavedKeywords } = await import("./saved-keywords");
|
||||
|
||||
const result = await getSavedKeywords({
|
||||
projectId: "project_1",
|
||||
tagIds: ["tag_1"],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
expect(mocks.listSavedKeywordsByProject).toHaveBeenCalledWith({
|
||||
projectId: "project_1",
|
||||
search: undefined,
|
||||
tagIds: ["tag_1"],
|
||||
tagNames: undefined,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
});
|
||||
expect(result.rows[0]?.tags).toEqual([
|
||||
{ id: "tag_1", name: "Content", normalizedName: "content", color: null },
|
||||
]);
|
||||
expect(result.tags).toEqual([
|
||||
{
|
||||
id: "tag_1",
|
||||
name: "Content",
|
||||
normalizedName: "content",
|
||||
color: null,
|
||||
keywordCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates saved keyword tags through add and remove operations", async () => {
|
||||
mocks.addTagsToSavedKeywords.mockResolvedValue({
|
||||
savedKeywordCount: 2,
|
||||
tags: [
|
||||
{
|
||||
id: "tag_1",
|
||||
name: "Content",
|
||||
normalizedName: "content",
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.removeTagsFromSavedKeywords.mockResolvedValue({
|
||||
savedKeywordCount: 2,
|
||||
removedCount: 2,
|
||||
tags: [{ id: "tag_2" }],
|
||||
});
|
||||
const { updateSavedKeywordTags } = await import("./saved-keywords");
|
||||
|
||||
const result = await updateSavedKeywordTags({
|
||||
projectId: "project_1",
|
||||
savedKeywordIds: ["saved_1", "saved_2"],
|
||||
addTags: ["Content"],
|
||||
removeTagIds: ["tag_2"],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
taggedCount: 2,
|
||||
addedTags: [
|
||||
{
|
||||
id: "tag_1",
|
||||
name: "Content",
|
||||
normalizedName: "content",
|
||||
color: null,
|
||||
},
|
||||
],
|
||||
removedTagIds: ["tag_2"],
|
||||
removedAssignments: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces tags only for the exact saved keyword rows returned by save", async () => {
|
||||
mocks.saveKeywordsToProject.mockResolvedValue([
|
||||
{ ...savedKeywordRow, id: "saved_us", keyword: "technical seo" },
|
||||
]);
|
||||
mocks.replaceTagsForSavedKeywords.mockResolvedValue({
|
||||
savedKeywordCount: 1,
|
||||
removedCount: 1,
|
||||
tags: [{ id: "tag_new", name: "US", normalizedName: "us" }],
|
||||
});
|
||||
const { saveKeywords } = await import("./saved-keywords");
|
||||
|
||||
const result = await saveKeywords({
|
||||
projectId: "project_1",
|
||||
keywords: ["technical seo"],
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
tags: ["US"],
|
||||
tagMode: "replace",
|
||||
});
|
||||
|
||||
expect(mocks.replaceTagsForSavedKeywords).toHaveBeenCalledWith({
|
||||
projectId: "project_1",
|
||||
savedKeywordIds: ["saved_us"],
|
||||
tagNames: ["US"],
|
||||
});
|
||||
expect(mocks.addTagsToSavedKeywords).not.toHaveBeenCalled();
|
||||
expect(result.savedKeywordIds).toEqual(["saved_us"]);
|
||||
});
|
||||
|
||||
it("rejects replace mode without replacement tags", async () => {
|
||||
mocks.saveKeywordsToProject.mockResolvedValue([savedKeywordRow]);
|
||||
const { saveKeywords } = await import("./saved-keywords");
|
||||
|
||||
await expect(
|
||||
saveKeywords({
|
||||
projectId: "project_1",
|
||||
keywords: ["technical seo"],
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
tagMode: "replace",
|
||||
}),
|
||||
).rejects.toThrow("Replacement tags are required");
|
||||
expect(mocks.replaceTagsForSavedKeywords).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,11 +1,19 @@
|
||||
import { KeywordResearchRepository } from "@/server/features/keywords/repositories/KeywordResearchRepository";
|
||||
import { 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 };
|
||||
if (input.tagMode === "replace") {
|
||||
const replacementTags = input.tags ?? [];
|
||||
if (replacementTags.length === 0) {
|
||||
throw new Error("Replacement tags are required when tagMode is replace.");
|
||||
}
|
||||
await KeywordResearchRepository.replaceTagsForSavedKeywords({
|
||||
projectId: input.projectId,
|
||||
savedKeywordIds,
|
||||
tagNames: replacementTags,
|
||||
});
|
||||
} else if ((input.tags?.length ?? 0) > 0) {
|
||||
await KeywordResearchRepository.addTagsToSavedKeywords({
|
||||
projectId: input.projectId,
|
||||
savedKeywordIds,
|
||||
tagNames: input.tags ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSavedKeywords(
|
||||
input: GetSavedKeywordsInput,
|
||||
): Promise<{ rows: SavedKeywordRow[] }> {
|
||||
const rows = await KeywordResearchRepository.listSavedKeywordsByProject(
|
||||
input.projectId,
|
||||
);
|
||||
return {
|
||||
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: rows.map(({ row, metric }) => ({
|
||||
rows: mapSavedKeywordRows(result.rows),
|
||||
totalCount: result.totalCount,
|
||||
tags: mapSavedKeywordTags(result.tags),
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportSavedKeywords(
|
||||
input: ExportSavedKeywordsInput,
|
||||
): Promise<{ rows: SavedKeywordRow[] }> {
|
||||
const result = await KeywordResearchRepository.listSavedKeywordsByProject({
|
||||
projectId: input.projectId,
|
||||
search: input.search,
|
||||
includeTerms: input.includeTerms,
|
||||
excludeTerms: input.excludeTerms,
|
||||
minVolume: input.minVolume,
|
||||
maxVolume: input.maxVolume,
|
||||
minCpc: input.minCpc,
|
||||
maxCpc: input.maxCpc,
|
||||
minDifficulty: input.minDifficulty,
|
||||
maxDifficulty: input.maxDifficulty,
|
||||
tagIds: input.tagIds,
|
||||
tagNames: input.tagNames,
|
||||
sort: input.sort,
|
||||
order: input.order,
|
||||
});
|
||||
|
||||
return { rows: mapSavedKeywordRows(result.rows) };
|
||||
}
|
||||
|
||||
export async function updateSavedKeywordTags(
|
||||
input: UpdateSavedKeywordTagsInput,
|
||||
) {
|
||||
const addResult =
|
||||
(input.addTags?.length ?? 0) > 0
|
||||
? await KeywordResearchRepository.addTagsToSavedKeywords({
|
||||
projectId: input.projectId,
|
||||
savedKeywordIds: input.savedKeywordIds,
|
||||
tagNames: input.addTags ?? [],
|
||||
})
|
||||
: { savedKeywordCount: 0, tags: [] };
|
||||
|
||||
const removeResult =
|
||||
(input.removeTagIds?.length ?? 0) > 0
|
||||
? await KeywordResearchRepository.removeTagsFromSavedKeywords({
|
||||
projectId: input.projectId,
|
||||
savedKeywordIds: input.savedKeywordIds,
|
||||
tagIds: input.removeTagIds ?? [],
|
||||
})
|
||||
: { removedCount: 0, savedKeywordCount: 0, tags: [] };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taggedCount: Math.max(
|
||||
addResult.savedKeywordCount,
|
||||
removeResult.savedKeywordCount,
|
||||
),
|
||||
addedTags: addResult.tags.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
normalizedName: tag.normalizedName,
|
||||
color: tag.color ?? null,
|
||||
})),
|
||||
removedTagIds: removeResult.tags.map((tag) => tag.id),
|
||||
removedAssignments: removeResult.removedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSavedKeywordRows(
|
||||
rows: Awaited<
|
||||
ReturnType<typeof KeywordResearchRepository.listSavedKeywordsByProject>
|
||||
>["rows"],
|
||||
): SavedKeywordRow[] {
|
||||
return rows.map(({ row, metric, tags }) => ({
|
||||
id: row.id,
|
||||
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,
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
191
src/server/mcp/tools/saved-keywords-tools.test.ts
Normal file
191
src/server/mcp/tools/saved-keywords-tools.test.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import type { ToolExtra } from "@/server/mcp/context";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProjectForOrganization: vi.fn(),
|
||||
getSavedKeywords: vi.fn(),
|
||||
saveKeywords: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||
ProjectService: {
|
||||
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
|
||||
KeywordResearchService: {
|
||||
getSavedKeywords: mocks.getSavedKeywords,
|
||||
saveKeywords: mocks.saveKeywords,
|
||||
},
|
||||
}));
|
||||
|
||||
const authContext = {
|
||||
userId: "user_123",
|
||||
userEmail: "alice@example.com",
|
||||
organizationId: "org_123",
|
||||
clientId: "client_123",
|
||||
scopes: ["mcp"],
|
||||
audience: "https://open-seo.test/mcp",
|
||||
subject: "user_123",
|
||||
baseUrl: "https://open-seo.test",
|
||||
};
|
||||
|
||||
const toolExtra: ToolExtra = {
|
||||
signal: new AbortController().signal,
|
||||
requestId: 1,
|
||||
sendNotification: vi.fn(),
|
||||
sendRequest: vi.fn(),
|
||||
authInfo: {
|
||||
token: "token",
|
||||
clientId: "client_123",
|
||||
scopes: ["mcp"],
|
||||
resource: new URL("https://open-seo.test/mcp"),
|
||||
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
|
||||
} satisfies AuthInfo,
|
||||
};
|
||||
|
||||
describe("saved keyword MCP tools", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mocks.getProjectForOrganization.mockReset();
|
||||
mocks.getProjectForOrganization.mockResolvedValue({ id: "project_1" });
|
||||
mocks.getSavedKeywords.mockReset();
|
||||
mocks.saveKeywords.mockReset();
|
||||
});
|
||||
|
||||
it("passes tags through save_keywords", async () => {
|
||||
mocks.saveKeywords.mockResolvedValue({
|
||||
success: true,
|
||||
savedKeywordIds: ["saved_1"],
|
||||
});
|
||||
const { saveKeywordsTool } = await import("./save-keywords");
|
||||
|
||||
const result = await saveKeywordsTool.handler(
|
||||
{
|
||||
projectId: "project_1",
|
||||
keywords: ["technical seo"],
|
||||
tags: ["Content"],
|
||||
},
|
||||
toolExtra,
|
||||
);
|
||||
|
||||
expect(mocks.saveKeywords).toHaveBeenCalledWith({
|
||||
projectId: "project_1",
|
||||
keywords: ["technical seo"],
|
||||
tags: ["Content"],
|
||||
tagMode: "append",
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
});
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
savedCount: 1,
|
||||
tags: ["Content"],
|
||||
tagMode: "append",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces tags through save_keywords when requested", async () => {
|
||||
mocks.saveKeywords.mockResolvedValue({
|
||||
success: true,
|
||||
savedKeywordIds: ["saved_1", "saved_2"],
|
||||
});
|
||||
const { saveKeywordsTool } = await import("./save-keywords");
|
||||
|
||||
const result = await saveKeywordsTool.handler(
|
||||
{
|
||||
projectId: "project_1",
|
||||
keywords: ["semrush alternative", "semrush pricing"],
|
||||
tags: ["cluster: affordable semrush alternatives"],
|
||||
tagMode: "replace",
|
||||
},
|
||||
toolExtra,
|
||||
);
|
||||
|
||||
expect(mocks.saveKeywords).toHaveBeenCalledWith({
|
||||
projectId: "project_1",
|
||||
keywords: ["semrush alternative", "semrush pricing"],
|
||||
tags: ["cluster: affordable semrush alternatives"],
|
||||
tagMode: "replace",
|
||||
locationCode: 2840,
|
||||
languageCode: "en",
|
||||
});
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
savedCount: 2,
|
||||
tags: ["cluster: affordable semrush alternatives"],
|
||||
tagMode: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects replace mode without replacement tags before saving", async () => {
|
||||
const { saveKeywordsTool } = await import("./save-keywords");
|
||||
|
||||
await expect(() =>
|
||||
saveKeywordsTool.handler(
|
||||
{
|
||||
projectId: "project_1",
|
||||
keywords: ["semrush alternative"],
|
||||
tagMode: "replace",
|
||||
},
|
||||
toolExtra,
|
||||
),
|
||||
).rejects.toThrow("Replacement tags are required");
|
||||
expect(mocks.saveKeywords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters list_saved_keywords by search and tag names", async () => {
|
||||
mocks.getSavedKeywords.mockResolvedValue({
|
||||
totalCount: 1,
|
||||
tags: [
|
||||
{
|
||||
id: "tag_1",
|
||||
name: "Content",
|
||||
normalizedName: "content",
|
||||
keywordCount: 1,
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
id: "saved_1",
|
||||
keyword: "technical seo",
|
||||
searchVolume: 120,
|
||||
keywordDifficulty: 18,
|
||||
cpc: 2.5,
|
||||
tags: [{ id: "tag_1", name: "Content", normalizedName: "content" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const { listSavedKeywordsTool } = await import("./list-saved-keywords");
|
||||
|
||||
const result = await listSavedKeywordsTool.handler(
|
||||
{
|
||||
projectId: "project_1",
|
||||
search: "technical",
|
||||
tags: ["Content"],
|
||||
limit: 50,
|
||||
},
|
||||
toolExtra,
|
||||
);
|
||||
|
||||
expect(mocks.getSavedKeywords).toHaveBeenCalledWith({
|
||||
projectId: "project_1",
|
||||
search: "technical",
|
||||
tagNames: ["Content"],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
});
|
||||
expect(result.structuredContent).toMatchObject({
|
||||
totalCount: 1,
|
||||
rows: [{ keyword: "technical seo" }],
|
||||
});
|
||||
const [content] = result.content;
|
||||
expect(content).toMatchObject({ type: "text" });
|
||||
expect(content?.type === "text" ? content.text : "").toContain(
|
||||
"tags:Content",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,14 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import {
|
||||
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",
|
||||
})
|
||||
|
||||
32
src/shared/saved-keyword-tags.test.ts
Normal file
32
src/shared/saved-keyword-tags.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeSavedKeywordTag,
|
||||
normalizeSavedKeywordTags,
|
||||
parseSavedKeywordTagInput,
|
||||
} from "./saved-keyword-tags";
|
||||
|
||||
describe("saved keyword tag helpers", () => {
|
||||
it("normalizes display and lookup names", () => {
|
||||
expect(normalizeSavedKeywordTag(" Technical SEO ")).toEqual({
|
||||
name: "Technical SEO",
|
||||
normalizedName: "technical seo",
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes tags by normalized name", () => {
|
||||
expect(
|
||||
normalizeSavedKeywordTags(["Content", "content", " technical seo "]),
|
||||
).toEqual([
|
||||
{ name: "Content", normalizedName: "content" },
|
||||
{ name: "technical seo", normalizedName: "technical seo" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses comma and newline separated tag input", () => {
|
||||
expect(parseSavedKeywordTagInput("content, technical seo\nBOFU")).toEqual([
|
||||
"content",
|
||||
"technical seo",
|
||||
"BOFU",
|
||||
]);
|
||||
});
|
||||
});
|
||||
35
src/shared/saved-keyword-tags.ts
Normal file
35
src/shared/saved-keyword-tags.ts
Normal file
@ -0,0 +1,35 @@
|
||||
const TAG_SEPARATOR = /[\n,]+/;
|
||||
|
||||
type NormalizedSavedKeywordTag = {
|
||||
name: string;
|
||||
normalizedName: string;
|
||||
};
|
||||
|
||||
export function normalizeSavedKeywordTag(
|
||||
value: string,
|
||||
): NormalizedSavedKeywordTag | null {
|
||||
const name = value.trim().replace(/\s+/g, " ");
|
||||
if (name.length === 0) return null;
|
||||
return {
|
||||
name,
|
||||
normalizedName: name.toLocaleLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSavedKeywordTags(
|
||||
values: readonly string[] | undefined,
|
||||
): NormalizedSavedKeywordTag[] {
|
||||
const tags = new Map<string, NormalizedSavedKeywordTag>();
|
||||
for (const value of values ?? []) {
|
||||
const tag = normalizeSavedKeywordTag(value);
|
||||
if (!tag || tags.has(tag.normalizedName)) continue;
|
||||
tags.set(tag.normalizedName, tag);
|
||||
}
|
||||
return [...tags.values()];
|
||||
}
|
||||
|
||||
export function parseSavedKeywordTagInput(value: string): string[] {
|
||||
return normalizeSavedKeywordTags(value.split(TAG_SEPARATOR)).map(
|
||||
(tag) => tag.name,
|
||||
);
|
||||
}
|
||||
58
src/shared/tag-colors.ts
Normal file
58
src/shared/tag-colors.ts
Normal file
@ -0,0 +1,58 @@
|
||||
export const TAG_COLOR_KEYS = [
|
||||
"slate",
|
||||
"rose",
|
||||
"amber",
|
||||
"lime",
|
||||
"emerald",
|
||||
"sky",
|
||||
"violet",
|
||||
"fuchsia",
|
||||
] as const;
|
||||
|
||||
export type TagColorKey = (typeof TAG_COLOR_KEYS)[number];
|
||||
|
||||
function isTagColorKey(value: unknown): value is TagColorKey {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(TAG_COLOR_KEYS as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function resolveTagColor(tag: {
|
||||
id: string;
|
||||
color?: string | null;
|
||||
}): TagColorKey {
|
||||
if (isTagColorKey(tag.color)) return tag.color;
|
||||
return TAG_COLOR_KEYS[hashString(tag.id) % TAG_COLOR_KEYS.length];
|
||||
}
|
||||
|
||||
const COLOR_CLASS: Record<TagColorKey, string> = {
|
||||
slate: "bg-slate-500",
|
||||
rose: "bg-rose-500",
|
||||
amber: "bg-amber-500",
|
||||
lime: "bg-lime-500",
|
||||
emerald: "bg-emerald-500",
|
||||
sky: "bg-sky-500",
|
||||
violet: "bg-violet-500",
|
||||
fuchsia: "bg-fuchsia-500",
|
||||
};
|
||||
|
||||
export function tagChipClass(color: TagColorKey): string {
|
||||
return `tag-chip-${color} ring-1 ring-inset`;
|
||||
}
|
||||
|
||||
export function tagDotClass(color: TagColorKey): string {
|
||||
return COLOR_CLASS[color];
|
||||
}
|
||||
|
||||
export function tagSwatchClass(color: TagColorKey): string {
|
||||
return COLOR_CLASS[color];
|
||||
}
|
||||
@ -35,6 +35,19 @@ export type SavedKeywordRow = {
|
||||
intent: string | null;
|
||||
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 = {
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user