mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-30 11:59:29 +02:00
refactor: move actions for selected items into its own component
This commit is contained in:
+2
-3
@@ -698,9 +698,6 @@ export default function Home() {
|
||||
<main className="flex flex-col items-center w-full max-w-3xl">
|
||||
<div className="w-full">
|
||||
<HomeHeader
|
||||
selectedProfiles={selectedProfiles}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
|
||||
onImportProfileDialogOpen={setImportProfileDialogOpen}
|
||||
@@ -731,6 +728,8 @@ export default function Home() {
|
||||
selectedGroupId={selectedGroupId}
|
||||
selectedProfiles={selectedProfiles}
|
||||
onSelectedProfilesChange={setSelectedProfiles}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableActionBarProps<TData>
|
||||
extends React.ComponentProps<typeof motion.div> {
|
||||
table: Table<TData>;
|
||||
visible?: boolean;
|
||||
portalContainer?: Element | DocumentFragment | null;
|
||||
}
|
||||
|
||||
function DataTableActionBar<TData>({
|
||||
table,
|
||||
visible: visibleProp,
|
||||
portalContainer: portalContainerProp,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataTableActionBarProps<TData>) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [table]);
|
||||
|
||||
const portalContainer =
|
||||
portalContainerProp ?? (mounted ? globalThis.document?.body : null);
|
||||
|
||||
if (!portalContainer) return null;
|
||||
|
||||
const visible =
|
||||
visibleProp ?? table.getFilteredSelectedRowModel().rows.length > 0;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
portalContainer,
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableActionBarActionProps
|
||||
extends React.ComponentProps<typeof Button> {
|
||||
tooltip?: string;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
function DataTableActionBarAction({
|
||||
size = "sm",
|
||||
tooltip,
|
||||
isPending,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DataTableActionBarActionProps) {
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={size}
|
||||
className={cn(
|
||||
"gap-1.5 border border-secondary bg-secondary/50 hover:bg-secondary/70 [&>svg]:size-3.5",
|
||||
size === "icon" ? "size-7" : "h-7",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || isPending}
|
||||
{...props}
|
||||
>
|
||||
{isPending ? (
|
||||
<div className="w-3.5 h-3.5 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) return trigger;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
|
||||
>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableActionBarSelectionProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
function DataTableActionBarSelection<TData>({
|
||||
table,
|
||||
}: DataTableActionBarSelectionProps<TData>) {
|
||||
const onClearSelection = React.useCallback(() => {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}, [table]);
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="mr-1 ml-2 h-4 w-px bg-border" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
|
||||
>
|
||||
<p>Clear selection</p>
|
||||
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
|
||||
<abbr title="Escape" className="no-underline">
|
||||
Esc
|
||||
</abbr>
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableActionBar,
|
||||
DataTableActionBarAction,
|
||||
DataTableActionBarSelection,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { LuSearch, LuTrash2, LuUsers, LuX } from "react-icons/lu";
|
||||
import { LuSearch, LuUsers, LuX } from "react-icons/lu";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
@@ -12,13 +12,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
selectedProfiles: string[];
|
||||
onBulkGroupAssignment: () => void;
|
||||
onBulkDelete: () => void;
|
||||
onSettingsDialogOpen: (open: boolean) => void;
|
||||
onProxyManagementDialogOpen: (open: boolean) => void;
|
||||
onGroupManagementDialogOpen: (open: boolean) => void;
|
||||
@@ -29,9 +25,6 @@ type Props = {
|
||||
};
|
||||
|
||||
const HomeHeader = ({
|
||||
selectedProfiles,
|
||||
onBulkGroupAssignment,
|
||||
onBulkDelete,
|
||||
onSettingsDialogOpen,
|
||||
onProxyManagementDialogOpen,
|
||||
onGroupManagementDialogOpen,
|
||||
@@ -58,36 +51,7 @@ const HomeHeader = ({
|
||||
>
|
||||
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
|
||||
</button>
|
||||
{selectedProfiles.length > 0 ? (
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedProfiles.length} profile
|
||||
{selectedProfiles.length !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onBulkGroupAssignment}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUsers className="w-4 h-4" />
|
||||
Assign to Group
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onBulkDelete}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CardTitle>Donut</CardTitle>
|
||||
)}
|
||||
<CardTitle>Donut</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
@@ -13,7 +14,13 @@ import { emit, listen } from "@tauri-apps/api/event";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import * as React from "react";
|
||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||
import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu";
|
||||
import {
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -62,6 +69,11 @@ import {
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile, ProxyCheckResult, StoredProxy } from "@/types";
|
||||
import {
|
||||
DataTableActionBar,
|
||||
DataTableActionBarAction,
|
||||
DataTableActionBarSelection,
|
||||
} from "./data-table-action-bar";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import MultipleSelector, { type Option } from "./multiple-selector";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
@@ -404,6 +416,8 @@ interface ProfilesDataTableProps {
|
||||
selectedGroupId: string | null;
|
||||
selectedProfiles: string[];
|
||||
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
|
||||
onBulkDelete?: () => void;
|
||||
onBulkGroupAssignment?: () => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -418,9 +432,62 @@ export function ProfilesDataTable({
|
||||
onAssignProfilesToGroup,
|
||||
selectedProfiles,
|
||||
onSelectedProfilesChange,
|
||||
onBulkDelete,
|
||||
onBulkGroupAssignment,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
// Sync external selectedProfiles with table's row selection state
|
||||
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
||||
const prevSelectedProfilesRef = React.useRef<string[]>(selectedProfiles);
|
||||
|
||||
// Update row selection when external selectedProfiles changes
|
||||
React.useEffect(() => {
|
||||
// Only update if selectedProfiles actually changed
|
||||
if (
|
||||
prevSelectedProfilesRef.current.length !== selectedProfiles.length ||
|
||||
!prevSelectedProfilesRef.current.every((id) =>
|
||||
selectedProfiles.includes(id),
|
||||
)
|
||||
) {
|
||||
const newSelection: RowSelectionState = {};
|
||||
for (const profileId of selectedProfiles) {
|
||||
newSelection[profileId] = true;
|
||||
}
|
||||
setRowSelection(newSelection);
|
||||
prevSelectedProfilesRef.current = selectedProfiles;
|
||||
}
|
||||
}, [selectedProfiles]);
|
||||
|
||||
// Update external selectedProfiles when table selection changes
|
||||
const handleRowSelectionChange = React.useCallback(
|
||||
(updater: React.SetStateAction<RowSelectionState>) => {
|
||||
setRowSelection((prevSelection) => {
|
||||
const newSelection =
|
||||
typeof updater === "function" ? updater(prevSelection) : updater;
|
||||
|
||||
const selectedIds = Object.keys(newSelection).filter(
|
||||
(id) => newSelection[id],
|
||||
);
|
||||
|
||||
// Only update external state if selection actually changed
|
||||
const prevIds = Object.keys(prevSelection).filter(
|
||||
(id) => prevSelection[id],
|
||||
);
|
||||
|
||||
if (
|
||||
selectedIds.length !== prevIds.length ||
|
||||
!selectedIds.every((id) => prevIds.includes(id))
|
||||
) {
|
||||
onSelectedProfilesChange(selectedIds);
|
||||
}
|
||||
|
||||
return newSelection;
|
||||
});
|
||||
},
|
||||
[onSelectedProfilesChange],
|
||||
);
|
||||
const [profileToRename, setProfileToRename] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [newProfileName, setNewProfileName] = React.useState("");
|
||||
@@ -1517,8 +1584,19 @@ export function ProfilesDataTable({
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
enableRowSelection: (row) => {
|
||||
const profile = row.original;
|
||||
const isRunning =
|
||||
browserState.isClient && runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating;
|
||||
},
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
@@ -1594,6 +1672,29 @@ export function ProfilesDataTable({
|
||||
confirmButtonText="Delete Profile"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
<DataTableActionBar table={table}>
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign to Group"
|
||||
onClick={onBulkGroupAssignment}
|
||||
size="icon"
|
||||
>
|
||||
<LuUsers />
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
{onBulkDelete && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Delete"
|
||||
onClick={onBulkDelete}
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
|
||||
>
|
||||
<LuTrash2 />
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
</DataTableActionBar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user