feat: unify multi select behavior across the app
This commit is contained in:
parent
0bfdc56c45
commit
8773662b16
129
src/client/components/table/TableBulkActionBar.tsx
Normal file
129
src/client/components/table/TableBulkActionBar.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import { ChevronDown, Download, Loader2, X } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function TableBulkActionBar({
|
||||||
|
selectedCount,
|
||||||
|
selectedLabel = "selected",
|
||||||
|
actions,
|
||||||
|
onClear,
|
||||||
|
placement = "fixed",
|
||||||
|
}: {
|
||||||
|
selectedCount: number;
|
||||||
|
selectedLabel?: string;
|
||||||
|
actions: ReactNode;
|
||||||
|
onClear: () => void;
|
||||||
|
placement?: "fixed" | "inline";
|
||||||
|
}) {
|
||||||
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
|
const wrapperClass =
|
||||||
|
placement === "fixed"
|
||||||
|
? "pointer-events-none fixed inset-x-0 bottom-6 z-30 flex justify-center px-4"
|
||||||
|
: "flex justify-center";
|
||||||
|
const toolbarClass =
|
||||||
|
placement === "fixed"
|
||||||
|
? "pointer-events-auto flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-300/85 shadow-2xl backdrop-blur"
|
||||||
|
: "flex items-stretch overflow-visible rounded-xl border border-base-content/15 bg-base-200";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapperClass}>
|
||||||
|
<div role="toolbar" aria-label="Bulk actions" className={toolbarClass}>
|
||||||
|
<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">{selectedLabel}</span>
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBulkActionButton({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
variant = "default",
|
||||||
|
}: {
|
||||||
|
icon?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: "default" | "danger";
|
||||||
|
}) {
|
||||||
|
const color =
|
||||||
|
variant === "danger"
|
||||||
|
? "text-error hover:bg-error/10"
|
||||||
|
: "text-base-content/85 hover:bg-base-content/10";
|
||||||
|
|
||||||
|
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 disabled:opacity-50 ${color}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBulkExportMenu({
|
||||||
|
actions,
|
||||||
|
busy,
|
||||||
|
}: {
|
||||||
|
actions: Array<{
|
||||||
|
label: ReactNode;
|
||||||
|
icon?: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>;
|
||||||
|
busy?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="dropdown dropdown-top dropdown-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabIndex={0}
|
||||||
|
disabled={busy}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{actions.map((action, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={busy || action.disabled}
|
||||||
|
>
|
||||||
|
{action.icon}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -132,6 +132,7 @@ export function DomainOverviewPage({
|
|||||||
currentSortOrder={state.currentSortOrder}
|
currentSortOrder={state.currentSortOrder}
|
||||||
searchDraft={state.searchDraft}
|
searchDraft={state.searchDraft}
|
||||||
selectedKeywords={state.selectedKeywords}
|
selectedKeywords={state.selectedKeywords}
|
||||||
|
setSelectedKeywords={state.setSelectedKeywords}
|
||||||
visibleKeywords={state.visibleKeywords}
|
visibleKeywords={state.visibleKeywords}
|
||||||
filteredKeywords={state.filteredKeywords}
|
filteredKeywords={state.filteredKeywords}
|
||||||
pagedPages={state.pagedPages}
|
pagedPages={state.pagedPages}
|
||||||
|
|||||||
@ -24,6 +24,11 @@ import {
|
|||||||
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
|
import {
|
||||||
|
TableBulkActionBar,
|
||||||
|
TableBulkActionButton,
|
||||||
|
TableBulkExportMenu,
|
||||||
|
} from "@/client/components/table/TableBulkActionBar";
|
||||||
import type {
|
import type {
|
||||||
DomainActiveTab,
|
DomainActiveTab,
|
||||||
DomainOverviewData,
|
DomainOverviewData,
|
||||||
@ -41,6 +46,7 @@ type Props = {
|
|||||||
currentSortOrder: SortOrder;
|
currentSortOrder: SortOrder;
|
||||||
searchDraft: string;
|
searchDraft: string;
|
||||||
selectedKeywords: Set<string>;
|
selectedKeywords: Set<string>;
|
||||||
|
setSelectedKeywords: Dispatch<SetStateAction<Set<string>>>;
|
||||||
visibleKeywords: string[];
|
visibleKeywords: string[];
|
||||||
filteredKeywords: KeywordRow[];
|
filteredKeywords: KeywordRow[];
|
||||||
pagedPages: PageRow[];
|
pagedPages: PageRow[];
|
||||||
@ -85,6 +91,7 @@ export function DomainResultsCard({
|
|||||||
currentSortOrder,
|
currentSortOrder,
|
||||||
searchDraft,
|
searchDraft,
|
||||||
selectedKeywords,
|
selectedKeywords,
|
||||||
|
setSelectedKeywords,
|
||||||
visibleKeywords,
|
visibleKeywords,
|
||||||
filteredKeywords,
|
filteredKeywords,
|
||||||
pagedPages,
|
pagedPages,
|
||||||
@ -119,6 +126,10 @@ export function DomainResultsCard({
|
|||||||
const exportTable = isKeywordsTab
|
const exportTable = isKeywordsTab
|
||||||
? keywordsToTable(filteredKeywords)
|
? keywordsToTable(filteredKeywords)
|
||||||
: pagesToTable(pagedPages);
|
: pagesToTable(pagedPages);
|
||||||
|
const selectedKeywordRows = filteredKeywords.filter((row) =>
|
||||||
|
selectedKeywords.has(row.keyword),
|
||||||
|
);
|
||||||
|
const selectedKeywordExportTable = keywordsToTable(selectedKeywordRows);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
const text = JSON.stringify(currentRows, null, 2);
|
const text = JSON.stringify(currentRows, null, 2);
|
||||||
@ -147,6 +158,28 @@ export function DomainResultsCard({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleExportSelectionToSheets = () => {
|
||||||
|
void exportTableToSheets({
|
||||||
|
headers: selectedKeywordExportTable.headers,
|
||||||
|
rows: selectedKeywordExportTable.rows,
|
||||||
|
feature: "domain_overview",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleDownloadSelectionCsv = () => {
|
||||||
|
downloadCsv(
|
||||||
|
`${overview.domain}-selected-keywords.csv`,
|
||||||
|
buildCsv(
|
||||||
|
selectedKeywordExportTable.headers,
|
||||||
|
selectedKeywordExportTable.rows,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "domain_overview",
|
||||||
|
result_count: selectedKeywordRows.length,
|
||||||
|
scope: "selection",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
<div className="border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||||
@ -190,20 +223,6 @@ export function DomainResultsCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{activeTab === "keywords" ? (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm"
|
|
||||||
onClick={onSaveKeywords}
|
|
||||||
disabled={selectedKeywords.size === 0 || !canSaveKeywords}
|
|
||||||
title={
|
|
||||||
!canSaveKeywords && selectedKeywords.size > 0
|
|
||||||
? "Re-run search to save keywords for the selected location"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Save className="size-4" /> Save Keywords
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className="dropdown dropdown-end">
|
<div className="dropdown dropdown-end">
|
||||||
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
|
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
@ -243,6 +262,38 @@ export function DomainResultsCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeTab === "keywords" ? (
|
||||||
|
<TableBulkActionBar
|
||||||
|
selectedCount={selectedKeywords.size}
|
||||||
|
onClear={() => setSelectedKeywords(new Set())}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center px-1.5">
|
||||||
|
<TableBulkActionButton
|
||||||
|
icon={<Save className="size-3.5" />}
|
||||||
|
onClick={onSaveKeywords}
|
||||||
|
disabled={!canSaveKeywords}
|
||||||
|
>
|
||||||
|
Save Keywords
|
||||||
|
</TableBulkActionButton>
|
||||||
|
<TableBulkExportMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Export to Sheets",
|
||||||
|
icon: <Sheet className="size-4" />,
|
||||||
|
onClick: handleExportSelectionToSheets,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Download CSV",
|
||||||
|
icon: <Download className="size-4" />,
|
||||||
|
onClick: handleDownloadSelectionCsv,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-base-300">
|
||||||
{isKeywordsTab ? (
|
{isKeywordsTab ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -298,6 +298,7 @@ export function useDomainOverviewController({
|
|||||||
searchDraft: domainFilters.searchDraft,
|
searchDraft: domainFilters.searchDraft,
|
||||||
setSearchDraft: domainFilters.setSearchDraft,
|
setSearchDraft: domainFilters.setSearchDraft,
|
||||||
selectedKeywords,
|
selectedKeywords,
|
||||||
|
setSelectedKeywords,
|
||||||
currentSortOrder,
|
currentSortOrder,
|
||||||
setSearchParams,
|
setSearchParams,
|
||||||
showFilters,
|
showFilters,
|
||||||
|
|||||||
@ -8,8 +8,13 @@ import {
|
|||||||
Sheet,
|
Sheet,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
import {
|
||||||
|
downloadKeywordResearchCsv,
|
||||||
|
KEYWORD_RESEARCH_HEADERS,
|
||||||
|
keywordResearchExportRow,
|
||||||
|
} from "@/client/features/keywords/state/keywordControllerActions";
|
||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import {
|
import {
|
||||||
AreaTrendChart,
|
AreaTrendChart,
|
||||||
OverviewStats,
|
OverviewStats,
|
||||||
@ -26,6 +31,11 @@ import {
|
|||||||
KeywordResearchPagination,
|
KeywordResearchPagination,
|
||||||
useKeywordResearchPagination,
|
useKeywordResearchPagination,
|
||||||
} from "./KeywordResearchPagination";
|
} from "./KeywordResearchPagination";
|
||||||
|
import {
|
||||||
|
TableBulkActionBar,
|
||||||
|
TableBulkActionButton,
|
||||||
|
TableBulkExportMenu,
|
||||||
|
} from "@/client/components/table/TableBulkActionBar";
|
||||||
|
|
||||||
const MONTH_SHORT_LABELS = [
|
const MONTH_SHORT_LABELS = [
|
||||||
"Jan",
|
"Jan",
|
||||||
@ -129,6 +139,9 @@ function DesktopTableCard({ controller }: Props) {
|
|||||||
: `Showing ${filteredRows.length} keywords`;
|
: `Showing ${filteredRows.length} keywords`;
|
||||||
|
|
||||||
const canExport = filteredRows.length > 0;
|
const canExport = filteredRows.length > 0;
|
||||||
|
const selectedExportRows = filteredRows
|
||||||
|
.filter((row) => selectedRows.has(row.keyword))
|
||||||
|
.map(keywordResearchExportRow);
|
||||||
const handleExportToSheets = () => {
|
const handleExportToSheets = () => {
|
||||||
void exportTableToSheets({
|
void exportTableToSheets({
|
||||||
headers: KEYWORD_RESEARCH_HEADERS,
|
headers: KEYWORD_RESEARCH_HEADERS,
|
||||||
@ -136,6 +149,21 @@ function DesktopTableCard({ controller }: Props) {
|
|||||||
feature: "keyword_research",
|
feature: "keyword_research",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const handleExportSelectionToSheets = () => {
|
||||||
|
void exportTableToSheets({
|
||||||
|
headers: KEYWORD_RESEARCH_HEADERS,
|
||||||
|
rows: selectedExportRows,
|
||||||
|
feature: "keyword_research",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleExportSelectionCsv = () => {
|
||||||
|
downloadKeywordResearchCsv(selectedExportRows);
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "keyword_research",
|
||||||
|
result_count: selectedExportRows.length,
|
||||||
|
scope: "selection",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 border border-base-300 rounded-xl bg-base-100 overflow-hidden">
|
||||||
@ -157,14 +185,6 @@ function DesktopTableCard({ controller }: Props) {
|
|||||||
{keywordCountLabel}
|
{keywordCountLabel}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-sm gap-1"
|
|
||||||
onClick={controller.handleSaveKeywords}
|
|
||||||
disabled={selectedRows.size === 0}
|
|
||||||
>
|
|
||||||
<Save className="size-3.5" />
|
|
||||||
<span className="hidden lg:inline">Save Keywords</span>
|
|
||||||
</button>
|
|
||||||
<div className="dropdown dropdown-end">
|
<div className="dropdown dropdown-end">
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@ -195,6 +215,35 @@ function DesktopTableCard({ controller }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TableBulkActionBar
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
onClear={() => controller.setSelectedRows(new Set())}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center px-1.5">
|
||||||
|
<TableBulkActionButton
|
||||||
|
icon={<Save className="size-3.5" />}
|
||||||
|
onClick={controller.handleSaveKeywords}
|
||||||
|
>
|
||||||
|
Save Keywords
|
||||||
|
</TableBulkActionButton>
|
||||||
|
<TableBulkExportMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Export to Sheets",
|
||||||
|
icon: <Sheet className="size-4" />,
|
||||||
|
onClick: handleExportSelectionToSheets,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Export CSV",
|
||||||
|
icon: <FileDown className="size-4" />,
|
||||||
|
onClick: handleExportSelectionCsv,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{showFilters ? <DesktopFilters controller={controller} /> : null}
|
{showFilters ? <DesktopFilters controller={controller} /> : null}
|
||||||
<KeywordResearchDesktopTable
|
<KeywordResearchDesktopTable
|
||||||
activeFilterCount={controller.activeFilterCount}
|
activeFilterCount={controller.activeFilterCount}
|
||||||
|
|||||||
@ -7,8 +7,13 @@ import {
|
|||||||
Sheet,
|
Sheet,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { KEYWORD_RESEARCH_HEADERS } from "@/client/features/keywords/state/keywordControllerActions";
|
import {
|
||||||
|
downloadKeywordResearchCsv,
|
||||||
|
KEYWORD_RESEARCH_HEADERS,
|
||||||
|
keywordResearchExportRow,
|
||||||
|
} from "@/client/features/keywords/state/keywordControllerActions";
|
||||||
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { SerpAnalysisCard } from "@/client/features/keywords/components";
|
import { SerpAnalysisCard } from "@/client/features/keywords/components";
|
||||||
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
import { KeywordResearchDesktopTable } from "./KeywordResearchDesktopTable";
|
||||||
import {
|
import {
|
||||||
@ -16,6 +21,11 @@ import {
|
|||||||
useKeywordResearchPagination,
|
useKeywordResearchPagination,
|
||||||
} from "./KeywordResearchPagination";
|
} from "./KeywordResearchPagination";
|
||||||
import type { KeywordResearchControllerState } from "./types";
|
import type { KeywordResearchControllerState } from "./types";
|
||||||
|
import {
|
||||||
|
TableBulkActionBar,
|
||||||
|
TableBulkActionButton,
|
||||||
|
TableBulkExportMenu,
|
||||||
|
} from "@/client/components/table/TableBulkActionBar";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
controller: KeywordResearchControllerState;
|
controller: KeywordResearchControllerState;
|
||||||
@ -89,6 +99,9 @@ function MobileKeywordResults({ controller }: Props) {
|
|||||||
: `Showing ${filteredRows.length} keywords`;
|
: `Showing ${filteredRows.length} keywords`;
|
||||||
|
|
||||||
const canExport = filteredRows.length > 0;
|
const canExport = filteredRows.length > 0;
|
||||||
|
const selectedExportRows = filteredRows
|
||||||
|
.filter((row) => selectedRows.has(row.keyword))
|
||||||
|
.map(keywordResearchExportRow);
|
||||||
const handleExportToSheets = () => {
|
const handleExportToSheets = () => {
|
||||||
void exportTableToSheets({
|
void exportTableToSheets({
|
||||||
headers: KEYWORD_RESEARCH_HEADERS,
|
headers: KEYWORD_RESEARCH_HEADERS,
|
||||||
@ -96,6 +109,21 @@ function MobileKeywordResults({ controller }: Props) {
|
|||||||
feature: "keyword_research",
|
feature: "keyword_research",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const handleExportSelectionToSheets = () => {
|
||||||
|
void exportTableToSheets({
|
||||||
|
headers: KEYWORD_RESEARCH_HEADERS,
|
||||||
|
rows: selectedExportRows,
|
||||||
|
feature: "keyword_research",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleExportSelectionCsv = () => {
|
||||||
|
downloadKeywordResearchCsv(selectedExportRows);
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "keyword_research",
|
||||||
|
result_count: selectedExportRows.length,
|
||||||
|
scope: "selection",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
@ -127,13 +155,6 @@ function MobileKeywordResults({ controller }: Props) {
|
|||||||
{keywordCountLabel}
|
{keywordCountLabel}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
|
||||||
className="btn btn-ghost btn-xs"
|
|
||||||
onClick={controller.handleSaveKeywords}
|
|
||||||
disabled={selectedRows.size === 0}
|
|
||||||
>
|
|
||||||
<Save className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
<div className="dropdown dropdown-end">
|
<div className="dropdown dropdown-end">
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@ -164,6 +185,35 @@ function MobileKeywordResults({ controller }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TableBulkActionBar
|
||||||
|
selectedCount={selectedRows.size}
|
||||||
|
onClear={() => controller.setSelectedRows(new Set())}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center px-1.5">
|
||||||
|
<TableBulkActionButton
|
||||||
|
icon={<Save className="size-3.5" />}
|
||||||
|
onClick={controller.handleSaveKeywords}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</TableBulkActionButton>
|
||||||
|
<TableBulkExportMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Export to Sheets",
|
||||||
|
icon: <Sheet className="size-4" />,
|
||||||
|
onClick: handleExportSelectionToSheets,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Export CSV",
|
||||||
|
icon: <FileDown className="size-4" />,
|
||||||
|
onClick: handleExportSelectionCsv,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{showFilters ? <MobileFilters controller={controller} /> : null}
|
{showFilters ? <MobileFilters controller={controller} /> : null}
|
||||||
|
|
||||||
<KeywordResearchDesktopTable
|
<KeywordResearchDesktopTable
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export const KEYWORD_RESEARCH_HEADERS = [
|
|||||||
"Intent",
|
"Intent",
|
||||||
];
|
];
|
||||||
|
|
||||||
function keywordResearchRow(row: KeywordResearchRow): CsvValue[] {
|
export function keywordResearchExportRow(row: KeywordResearchRow): CsvValue[] {
|
||||||
return [
|
return [
|
||||||
row.keyword,
|
row.keyword,
|
||||||
row.searchVolume ?? "",
|
row.searchVolume ?? "",
|
||||||
@ -145,12 +145,8 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sheetsExportRows: CsvValue[][] = useMemo(
|
const sheetsExportRows: CsvValue[][] = useMemo(
|
||||||
() =>
|
() => filteredRows.map(keywordResearchExportRow),
|
||||||
(selectedRows.size > 0
|
[filteredRows],
|
||||||
? filteredRows.filter((row) => selectedRows.has(row.keyword))
|
|
||||||
: filteredRows
|
|
||||||
).map(keywordResearchRow),
|
|
||||||
[filteredRows, selectedRows],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const exportCsv = () => {
|
const exportCsv = () => {
|
||||||
@ -158,8 +154,19 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
|
|||||||
toast.error("No data to export");
|
toast.error("No data to export");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
downloadKeywordResearchCsv(sheetsExportRows);
|
||||||
|
captureClientEvent("data:export", {
|
||||||
|
source_feature: "keyword_research",
|
||||||
|
result_count: sheetsExportRows.length,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadKeywordResearchCsv(rows: CsvValue[][]) {
|
||||||
// CSV file keeps cents-formatted CPC/competition for human readability.
|
// CSV file keeps cents-formatted CPC/competition for human readability.
|
||||||
const csvRows = sheetsExportRows.map((row) =>
|
const csvRows = rows.map((row) =>
|
||||||
row.map((cell, idx) =>
|
row.map((cell, idx) =>
|
||||||
(idx === 2 || idx === 3) && typeof cell === "number"
|
(idx === 2 || idx === 3) && typeof cell === "number"
|
||||||
? cell.toFixed(2)
|
? cell.toFixed(2)
|
||||||
@ -170,11 +177,4 @@ export function useSaveAndExportActions(params: SaveExportActionParams) {
|
|||||||
"keyword-research.csv",
|
"keyword-research.csv",
|
||||||
buildCsv(KEYWORD_RESEARCH_HEADERS, csvRows),
|
buildCsv(KEYWORD_RESEARCH_HEADERS, csvRows),
|
||||||
);
|
);
|
||||||
captureClientEvent("data:export", {
|
|
||||||
source_feature: "keyword_research",
|
|
||||||
result_count: sheetsExportRows.length,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return { handleSaveKeywords, confirmSave, exportCsv, sheetsExportRows };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,11 @@ import {
|
|||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { Loader2, AlertCircle, X } from "lucide-react";
|
import { Loader2, AlertCircle, FileDown, Sheet, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { buildCsv, downloadCsv } from "@/client/lib/csv";
|
||||||
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
|
import { getDomainKeywordSuggestions } from "@/serverFunctions/domain";
|
||||||
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
import { addTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
@ -15,6 +18,11 @@ import {
|
|||||||
makeSelectionColumn,
|
makeSelectionColumn,
|
||||||
useAppTable,
|
useAppTable,
|
||||||
} from "@/client/components/table/AppDataTable";
|
} from "@/client/components/table/AppDataTable";
|
||||||
|
import {
|
||||||
|
TableBulkActionBar,
|
||||||
|
TableBulkActionButton,
|
||||||
|
TableBulkExportMenu,
|
||||||
|
} from "@/client/components/table/TableBulkActionBar";
|
||||||
import { SortableHeader } from "./RankTrackingColumns";
|
import { SortableHeader } from "./RankTrackingColumns";
|
||||||
import {
|
import {
|
||||||
applyShiftRangeSelection,
|
applyShiftRangeSelection,
|
||||||
@ -29,6 +37,12 @@ type SuggestedKeyword = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PRE_SELECT_COUNT = 20;
|
const PRE_SELECT_COUNT = 20;
|
||||||
|
const SUGGESTED_KEYWORD_EXPORT_HEADERS = [
|
||||||
|
"Keyword",
|
||||||
|
"Position",
|
||||||
|
"Volume",
|
||||||
|
"Traffic",
|
||||||
|
];
|
||||||
|
|
||||||
const baseColumns: ColumnDef<SuggestedKeyword>[] = [
|
const baseColumns: ColumnDef<SuggestedKeyword>[] = [
|
||||||
{
|
{
|
||||||
@ -228,6 +242,32 @@ export function KeywordSuggestionStep({
|
|||||||
addMutation.mutate(selectedKeywords);
|
addMutation.mutate(selectedKeywords);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const selectedSuggestionRows = table
|
||||||
|
.getSelectedRowModel()
|
||||||
|
.rows.map((row) => row.original);
|
||||||
|
const selectedSuggestionExportRows = selectedSuggestionRows.map((row) => [
|
||||||
|
row.keyword,
|
||||||
|
row.position ?? "",
|
||||||
|
row.searchVolume ?? "",
|
||||||
|
row.traffic ?? "",
|
||||||
|
]);
|
||||||
|
const handleExportSelectionToSheets = () => {
|
||||||
|
void exportTableToSheets({
|
||||||
|
headers: SUGGESTED_KEYWORD_EXPORT_HEADERS,
|
||||||
|
rows: selectedSuggestionExportRows,
|
||||||
|
feature: "rank_tracking",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleExportSelectionCsv = () => {
|
||||||
|
downloadCsv(
|
||||||
|
`rank-tracking-suggestions-${domain}.csv`,
|
||||||
|
buildCsv(SUGGESTED_KEYWORD_EXPORT_HEADERS, selectedSuggestionExportRows),
|
||||||
|
);
|
||||||
|
captureClientEvent("rank_tracking:suggestions_export_csv", {
|
||||||
|
result_count: selectedSuggestionRows.length,
|
||||||
|
scope: "selection",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const sectionHeader = (title: string) => (
|
const sectionHeader = (title: string) => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -307,9 +347,6 @@ export function KeywordSuggestionStep({
|
|||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/60">
|
||||||
We found {data.length} keywords {domain} ranks for.
|
We found {data.length} keywords {domain} ranks for.
|
||||||
</p>
|
</p>
|
||||||
<span className="text-xs text-base-content/50">
|
|
||||||
{selectedCount} of {data.length} selected
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDataTable
|
<AppDataTable
|
||||||
@ -329,20 +366,45 @@ export function KeywordSuggestionStep({
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-1">
|
<div className="flex items-center justify-between gap-3 pt-1">
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
<button className="btn btn-ghost btn-sm" onClick={onClose}>
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
<button
|
<TableBulkActionBar
|
||||||
className="btn btn-primary btn-sm"
|
selectedCount={selectedCount}
|
||||||
disabled={selectedCount === 0 || addMutation.isPending}
|
selectedLabel={`of ${data.length} selected`}
|
||||||
onClick={handleAdd}
|
onClear={() => table.resetRowSelection()}
|
||||||
>
|
placement="inline"
|
||||||
{addMutation.isPending && (
|
actions={
|
||||||
|
<div className="flex items-center px-1.5">
|
||||||
|
<TableBulkActionButton
|
||||||
|
icon={
|
||||||
|
addMutation.isPending ? (
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
)}
|
) : undefined
|
||||||
Add {selectedCount} Keyword{selectedCount !== 1 ? "s" : ""}
|
}
|
||||||
</button>
|
onClick={handleAdd}
|
||||||
|
disabled={addMutation.isPending}
|
||||||
|
>
|
||||||
|
Add Keyword{selectedCount !== 1 ? "s" : ""}
|
||||||
|
</TableBulkActionButton>
|
||||||
|
<TableBulkExportMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Export to Sheets",
|
||||||
|
icon: <Sheet className="size-4" />,
|
||||||
|
onClick: handleExportSelectionToSheets,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Export CSV",
|
||||||
|
icon: <FileDown className="size-4" />,
|
||||||
|
onClick: handleExportSelectionCsv,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,16 +1,26 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, Trash2 } from "lucide-react";
|
import { FileDown, Loader2, Sheet, Trash2 } from "lucide-react";
|
||||||
import { Modal } from "@/client/components/Modal";
|
import { Modal } from "@/client/components/Modal";
|
||||||
import {
|
import {
|
||||||
AppDataTable,
|
AppDataTable,
|
||||||
useAppTable,
|
useAppTable,
|
||||||
} from "@/client/components/table/AppDataTable";
|
} from "@/client/components/table/AppDataTable";
|
||||||
|
import {
|
||||||
|
TableBulkActionBar,
|
||||||
|
TableBulkActionButton,
|
||||||
|
TableBulkExportMenu,
|
||||||
|
} from "@/client/components/table/TableBulkActionBar";
|
||||||
|
import { buildCsv } from "@/client/lib/csv";
|
||||||
|
import { downloadCsv } from "@/client/lib/csv";
|
||||||
|
import { exportTableToSheets } from "@/client/lib/exportToSheets";
|
||||||
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
import { removeTrackingKeywords } from "@/serverFunctions/rank-tracking";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
|
||||||
import { useRankTrackingColumns } from "./RankTrackingColumns";
|
import { useRankTrackingColumns } from "./RankTrackingColumns";
|
||||||
|
import { buildRankTrackingExport } from "./RankTrackingTableParts";
|
||||||
import type { SelectionAnchor } from "@/client/components/table/tableSelection";
|
import type { SelectionAnchor } from "@/client/components/table/tableSelection";
|
||||||
|
|
||||||
export function RankTrackingTable({
|
export function RankTrackingTable({
|
||||||
@ -59,6 +69,38 @@ export function RankTrackingTable({
|
|||||||
// Only includes rows that are in the current data (respects parent filtering)
|
// Only includes rows that are in the current data (respects parent filtering)
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
const selectedRows = table.getSelectedRowModel().rows;
|
||||||
const selectedCount = selectedRows.length;
|
const selectedCount = selectedRows.length;
|
||||||
|
const selectedRankRows = selectedRows.map((row) => row.original);
|
||||||
|
|
||||||
|
const exportSelectionToSheets = () => {
|
||||||
|
const { headers, rows: exportRows } = buildRankTrackingExport(
|
||||||
|
selectedRankRows,
|
||||||
|
showDesktop,
|
||||||
|
showMobile,
|
||||||
|
);
|
||||||
|
void exportTableToSheets({
|
||||||
|
headers,
|
||||||
|
rows: exportRows,
|
||||||
|
feature: "rank_tracking",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportSelectionCsv = () => {
|
||||||
|
const { headers, rows: exportRows } = buildRankTrackingExport(
|
||||||
|
selectedRankRows,
|
||||||
|
showDesktop,
|
||||||
|
showMobile,
|
||||||
|
);
|
||||||
|
const csvRows = exportRows.map((row) =>
|
||||||
|
row.map((cell, idx) =>
|
||||||
|
idx === 3 && typeof cell === "number" ? cell.toFixed(2) : cell,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
downloadCsv(
|
||||||
|
`rank-tracking-${domain}-selected.csv`,
|
||||||
|
buildCsv(headers, csvRows),
|
||||||
|
);
|
||||||
|
captureClientEvent("rank_tracking:export_csv", { scope: "selection" });
|
||||||
|
};
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (keywordIds: string[]) =>
|
mutationFn: (keywordIds: string[]) =>
|
||||||
@ -101,28 +143,35 @@ export function RankTrackingTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Bulk action bar */}
|
<TableBulkActionBar
|
||||||
{selectedCount > 0 && (
|
selectedCount={selectedCount}
|
||||||
<div className="flex items-center gap-3 rounded-lg bg-base-200 px-3 py-2 text-sm">
|
onClear={() => table.resetRowSelection()}
|
||||||
<span className="text-base-content/70">
|
actions={
|
||||||
{selectedCount} keyword
|
<div className="flex items-center px-1.5">
|
||||||
{selectedCount !== 1 ? "s" : ""} selected
|
<TableBulkActionButton
|
||||||
</span>
|
icon={<Trash2 className="size-3.5" />}
|
||||||
<button
|
|
||||||
className="btn btn-error btn-xs gap-1"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
onClick={() => setShowConfirm(true)}
|
||||||
|
variant="danger"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3" />
|
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</TableBulkActionButton>
|
||||||
<button
|
<TableBulkExportMenu
|
||||||
className="btn btn-ghost btn-xs"
|
actions={[
|
||||||
onClick={() => table.resetRowSelection()}
|
{
|
||||||
>
|
label: "Export to Sheets",
|
||||||
Clear
|
icon: <Sheet className="size-4" />,
|
||||||
</button>
|
onClick: exportSelectionToSheets,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Export CSV",
|
||||||
|
icon: <FileDown className="size-4" />,
|
||||||
|
onClick: exportSelectionCsv,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Confirm modal */}
|
{/* Confirm modal */}
|
||||||
{showConfirm && (
|
{showConfirm && (
|
||||||
|
|||||||
@ -176,7 +176,7 @@ function csvChange(
|
|||||||
return previous - current;
|
return previous - current;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRankTrackingExport(
|
export function buildRankTrackingExport(
|
||||||
sorted: RankTrackingRow[],
|
sorted: RankTrackingRow[],
|
||||||
showDesktop: boolean,
|
showDesktop: boolean,
|
||||||
showMobile: boolean,
|
showMobile: boolean,
|
||||||
|
|||||||
@ -1,15 +1,9 @@
|
|||||||
|
import { Copy, FileDown, Sheet, Tags, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
TableBulkActionBar,
|
||||||
Copy,
|
TableBulkActionButton,
|
||||||
Download,
|
TableBulkExportMenu,
|
||||||
FileDown,
|
} from "@/client/components/table/TableBulkActionBar";
|
||||||
Loader2,
|
|
||||||
Sheet,
|
|
||||||
Tags,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export function SavedKeywordsBulkActionBar({
|
export function SavedKeywordsBulkActionBar({
|
||||||
selectedCount,
|
selectedCount,
|
||||||
@ -34,119 +28,52 @@ export function SavedKeywordsBulkActionBar({
|
|||||||
const exportBusy = exportingSelection != null;
|
const exportBusy = exportingSelection != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed inset-x-0 bottom-6 z-30 flex justify-center px-4">
|
<TableBulkActionBar
|
||||||
<div
|
selectedCount={selectedCount}
|
||||||
role="toolbar"
|
onClear={onClear}
|
||||||
aria-label="Bulk actions"
|
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">
|
<div className="flex items-center gap-0.5 px-1.5">
|
||||||
<ActionButton
|
<TableBulkActionButton
|
||||||
icon={<Tags className="size-3.5" />}
|
icon={<Tags className="size-3.5" />}
|
||||||
onClick={onOpenTags}
|
onClick={onOpenTags}
|
||||||
>
|
>
|
||||||
Tag
|
Tag
|
||||||
</ActionButton>
|
</TableBulkActionButton>
|
||||||
|
|
||||||
<div className="dropdown dropdown-top dropdown-end">
|
<TableBulkExportMenu
|
||||||
<button
|
busy={exportBusy}
|
||||||
type="button"
|
actions={[
|
||||||
tabIndex={0}
|
{
|
||||||
disabled={exportBusy}
|
label: "Copy keywords",
|
||||||
aria-haspopup="menu"
|
icon: <Copy className="size-4" />,
|
||||||
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"
|
onClick: onCopy,
|
||||||
>
|
},
|
||||||
{exportBusy ? (
|
{
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
label: "Export to Sheets",
|
||||||
) : (
|
icon: <Sheet className="size-4" />,
|
||||||
<Download className="size-3.5" />
|
onClick: onExportSheets,
|
||||||
)}
|
},
|
||||||
Export
|
{
|
||||||
<ChevronDown className="size-3 opacity-60" />
|
label: "Export CSV",
|
||||||
</button>
|
icon: <FileDown className="size-4" />,
|
||||||
<ul
|
onClick: onExportCsv,
|
||||||
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>
|
||||||
|
|
||||||
<div className="flex items-center border-l border-base-content/10 px-1.5">
|
<div className="flex items-center border-l border-base-content/10 px-1.5">
|
||||||
<button
|
<TableBulkActionButton
|
||||||
type="button"
|
icon={<Trash2 className="size-3.5" />}
|
||||||
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}
|
onClick={onDelete}
|
||||||
|
variant="danger"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</TableBulkActionButton>
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user