Skip to content

Commit

Permalink
feat: delete platform entries
Browse files Browse the repository at this point in the history
You can now delete platform entries from the client

resolves #101
  • Loading branch information
JMBeresford committed Jan 21, 2025
1 parent 4e257e3 commit 3f31a07
Show file tree
Hide file tree
Showing 8 changed files with 287 additions and 33 deletions.
159 changes: 159 additions & 0 deletions packages/client/web/src/components/modals/delete-platform/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { useCallback, useState } from "react";
import {
DialogContent,
DialogHeader,
Dialog,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { LoaderCircleIcon } from "lucide-react";
import { useDeletePlatforms } from "@/mutations/useDeletePlatforms";
import { Route as RootRoute } from "@/routes/__root";

export function DeletePlatformModal() {
const { toast } = useToast();
const [deleteFromDisk, setDeleteFromDisk] = useState(false);
const [blacklistEntries, setBlacklistEntries] = useState(false);
const { deletePlatformModal } = RootRoute.useSearch();
const platform = deletePlatformModal?.platform;
const navigate = RootRoute.useNavigate();

const { mutateAsync: deletePlatforms, isPending } = useDeletePlatforms();

const handleDelete = useCallback(async () => {
try {
if (!platform) {
return;
}

const res = await deletePlatforms({
ids: [platform.id],
deleteFromDisk,
blacklistEntries,
});

if (!res.platformsDeleted.length) {
throw new Error("Failed to delete platform");
}

toast({
title: `Platform deleted: ${platform.name}`,
});

navigate({
to: "/home",
search: (prev) => ({ ...prev, deletePlatformModal: undefined }),
}).catch(console.error);
} catch (e) {
console.error(e);
toast({
title: "Failed to delete platform",
});
}
}, [
deletePlatforms,
platform,
deleteFromDisk,
blacklistEntries,
toast,
navigate,
]);

return (
<Dialog
open={deletePlatformModal?.open}
onOpenChange={(open) => {
if (!open) {
navigate({
search: (prev) => ({ ...prev, deletePlatformModal: undefined }),
}).catch(console.error);
}
}}
>
<DialogContent className="max-w-[60ch]">
<DialogHeader>
<DialogTitle>Delete Platform</DialogTitle>
<DialogDescription>
Are you sure you want to delete {platform?.name ?? "this platform"}?
</DialogDescription>
</DialogHeader>

<p className="pb-2">
You can either delete the entry from the database or delete the
platform from the disk. Deleting only the entry will leave your file
system as is, but Retrom will ignore the platform&apos;s directory
moving forward.
</p>

<div className="flex flex-col gap-4">
<div className="flex items-top gap-2">
<Checkbox
id="delete-from-disk"
checked={deleteFromDisk}
disabled={platform?.thirdParty}
onCheckedChange={(event) => setDeleteFromDisk(!!event)}
/>

<div
className={cn(
"grid gap-1 5 leading-none",
platform?.thirdParty && "opacity-50",
)}
>
<label htmlFor="delete-from-disk">Delete from disk</label>

<p className="text-sm text-muted-foreground">
This will alter the the file system
</p>
</div>
</div>

<div className="flex items-top gap-2">
<Checkbox
id="blacklist-entries"
checked={blacklistEntries}
onCheckedChange={(event) => setBlacklistEntries(!!event)}
/>

<div className="grid gap-1 5 leading-none">
<label htmlFor="blacklist-entries">Blacklist entries</label>

<p className="text-sm text-muted-foreground max-w-[45ch]">
Enabling this will prevent the platform and its files from being
re-imported in any future library scans
</p>
</div>
</div>
</div>

<DialogFooter>
<div className="flex gap-2">
<DialogClose asChild>
<Button>Cancel</Button>
</DialogClose>

<Button
className="relative"
variant="destructive"
onClick={handleDelete}
>
<LoaderCircleIcon
className={cn(
"animate-spin absolute",
!isPending && "opacity-0",
)}
/>
<p className={cn(isPending && "opacity-0")}>Delete</p>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
96 changes: 87 additions & 9 deletions packages/client/web/src/components/side-bar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import {
import { usePlatforms } from "@/queries/usePlatforms";
import { useGames } from "@/queries/useGames";
import { Game, StorageType } from "@/generated/retrom/models/games";
import { GameMetadata } from "@/generated/retrom/models/metadata";
import {
GameMetadata,
PlatformMetadata,
} from "@/generated/retrom/models/metadata";
import { Link, useLocation } from "@tanstack/react-router";
import { useFilterAndSort } from "./filter-sort-context";
import { FiltersAndSorting } from "./filters-and-sorting";
Expand All @@ -26,6 +29,18 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
import { useInstallationStateQuery } from "@/queries/useInstallationState";
import { InstallationStatus } from "@/generated/retrom/client/client-utils";
import { Skeleton } from "../ui/skeleton";
import { EllipsisVertical } from "lucide-react";
import { Platform } from "@/generated/retrom/models/platforms";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { DropdownMenuTriggerProps } from "@radix-ui/react-dropdown-menu";
import { Button } from "../ui/button";

type PlatformWithMetadata = Platform & { metadata?: PlatformMetadata };

export function SideBar() {
const {
Expand Down Expand Up @@ -60,7 +75,7 @@ export function SideBar() {
const loading = platformStatus === "pending" || gameStatus === "pending";
const error = platformStatus === "error" || gameStatus === "error";

const platformsWithMetadata = useMemo(() => {
const platformsWithMetadata: PlatformWithMetadata[] = useMemo(() => {
const platforms =
platformData?.platforms.map((platform) => {
const platformMetadata = platformData.metadata.find(
Expand Down Expand Up @@ -215,16 +230,27 @@ export function SideBar() {
value={platform.id.toString()}
className={cn("border-b-0 w-full max-w-full")}
>
<AccordionTrigger
<div
className={cn(
"py-2 font-medium overflow-hidden relative",
"group grid grid-cols-[1fr_auto] border-b border-transparent",
"hover:border-border transition-all",
)}
>
<h3 className="text-left whitespace-nowrap overflow-ellipsis overflow-hidden">
{name}
</h3>
<span className="sr-only">Toggle</span>
</AccordionTrigger>
<AccordionTrigger
hideIcon
className={cn(
"group py-2 font-medium overflow-hidden relative hover:no-underline",
)}
>
<div className="flex w-full">
<h3 className="text-left whitespace-nowrap overflow-ellipsis overflow-hidden">
{name}
</h3>
<span className="sr-only">Toggle</span>
</div>
</AccordionTrigger>
<PlatformContextMenu platform={platform} />
</div>

<AccordionContent>
<ul>
Expand Down Expand Up @@ -305,3 +331,55 @@ export function SideBar() {
</aside>
);
}

function PlatformContextMenu(
props: DropdownMenuTriggerProps & { platform: PlatformWithMetadata },
) {
const { platform, ...rest } = props;

const name = platform.metadata?.name || getFileStub(platform.path);
const { id, thirdParty } = platform;

return (
<DropdownMenu>
<DropdownMenuTrigger
asChild
{...rest}
className={cn(
"opacity-0 transition-opacity active:opacity-100",
"group-hover:opacity-100 data-[state=open]:opacity-100",
)}
>
<Button
size="icon"
variant="ghost"
className="w-fit h-fit aspect-square p-2 my-auto"
>
<EllipsisVertical className={cn("w-[1rem] h-[1rem]")} />
</Button>
</DropdownMenuTrigger>

<DropdownMenuContent>
<DropdownMenuItem
asChild
className="text-destructive-text"
onClick={(e) => {
e.stopPropagation();
}}
>
<Link
search={(prev) => ({
...prev,
deletePlatformModal: {
open: true,
platform: { id, name, thirdParty },
},
})}
>
Delete
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
10 changes: 7 additions & 3 deletions packages/client/web/src/components/ui/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ AccordionItem.displayName = "AccordionItem";

const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
hideIcon?: boolean;
}
>(({ className, children, hideIcon, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
Expand All @@ -36,7 +38,9 @@ const AccordionTrigger = React.forwardRef<
) : (
<>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
{hideIcon ? null : (
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
)}
</>
)}
</AccordionPrimitive.Trigger>
Expand Down
28 changes: 13 additions & 15 deletions packages/client/web/src/mutations/useDeletePlatforms.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import { Platform } from "@/generated/retrom/models/platforms";
import { DeletePlatformsRequest } from "@/generated/retrom/services";
import { useRetromClient } from "@/providers/retrom-client";
import { useMutation, useQueryClient } from "@tanstack/react-query";

export function useDeletePlatforms(platforms: Platform[]) {
export function useDeletePlatforms() {
const retromClient = useRetromClient();
const queryClient = useQueryClient();

return useMutation({
mutationKey: ["deletePlatforms", platforms],
mutationFn: async () =>
retromClient.platformClient.deletePlatforms({
ids: platforms.map((platform) => platform.id),
}),
mutationKey: ["deletePlatforms"],
mutationFn: async (req: DeletePlatformsRequest) =>
retromClient.platformClient.deletePlatforms(req),
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: [
"platforms",
"platform-metadata",
"library",
...platforms.map((g) => g.id),
],
});
queryClient
.invalidateQueries({
predicate: ({ queryKey }) =>
["platforms", "platform-metadata", "games", "game-metadata"].some(
(key) => queryKey.includes(key),
),
})
.catch(console.error);
},
});
}
7 changes: 3 additions & 4 deletions packages/client/web/src/providers/modal-action/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,14 @@ export function ModalActionProvider(props: React.PropsWithChildren) {

const openModal: ModalActionContext["openModal"] = useCallback(
(key, props) => {
const { title, description } = props ?? {};
setActiveModalProps(props);

void navigate({
navigate({
search: (prev) => ({
...prev,
[key]: { open: true, title, description },
[key]: { open: true, ...props },
}),
});
}).catch(console.error);
},
[navigate],
);
Expand Down
2 changes: 2 additions & 0 deletions packages/client/web/src/routes/(windowed)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useConfig } from "@/providers/config";
import { ServerFileExplorerModal } from "@/components/modals/server-file-explorer";
import { ModalActionProvider } from "@/providers/modal-action";
import { ConfirmModal } from "@/components/modals/confirm";
import { DeletePlatformModal } from "@/components/modals/delete-platform";

export const Route = createFileRoute("/(windowed)/_layout")({
component: LayoutComponent,
Expand Down Expand Up @@ -104,6 +105,7 @@ function LayoutComponent() {
<ConfigModal />
<ConfirmModal />
<ServerFileExplorerModal />
<DeletePlatformModal />
</ModalActionProvider>
)}

Expand Down
Loading

0 comments on commit 3f31a07

Please sign in to comment.