Skip to content

Commit

Permalink
feature(web): Improve merging of tags by simple drag and drop #144 (#154
Browse files Browse the repository at this point in the history
)

* Improve merging of tags by simple drag and drop #144
Added drag&drop functionality
Allowing sorting the tags by name, as this is more intuitive

* Improve merging of tags by simple drag and drop #144
minor renamings
removed some unnecessary code

* Improve merging of tags by simple drag and drop #144
extracted out the drag and drop functionality to be more encapsulated and reusable

* Improve merging of tags by simple drag and drop #144
improved the usage sorter to additionally compare by name if the usage is the same

* Improve merging of tags by simple drag and drop #144
replaced checkboxes with toggles floating on the right

---------

Co-authored-by: kamtschatka <simon.schatka@gmx.at>
  • Loading branch information
kamtschatka and kamtschatka authored May 18, 2024
1 parent 6eea671 commit 1fee129
Show file tree
Hide file tree
Showing 6 changed files with 365 additions and 43 deletions.
172 changes: 129 additions & 43 deletions apps/web/components/dashboard/tags/AllTagsView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import Link from "next/link";
import React from "react";
import { ActionButton } from "@/components/ui/action-button";
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { Button } from "@/components/ui/button";
Expand All @@ -11,14 +11,19 @@ import {
} from "@/components/ui/collapsible";
import InfoTooltip from "@/components/ui/info-tooltip";
import { Separator } from "@/components/ui/separator";
import { Toggle } from "@/components/ui/toggle";
import { toast } from "@/components/ui/use-toast";
import { useDragAndDrop } from "@/lib/drag-and-drop";
import { api } from "@/lib/trpc";
import { X } from "lucide-react";
import Draggable from "react-draggable";

import type { ZGetTagResponse } from "@hoarder/shared/types/tags";
import { useDeleteUnusedTags } from "@hoarder/shared-react/hooks/tags";
import {
useDeleteUnusedTags,
useMergeTag,
} from "@hoarder/shared-react/hooks/tags";

import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog";
import { TagPill } from "./TagPill";

function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) {
const { mutate, isPending } = useDeleteUnusedTags({
Expand Down Expand Up @@ -55,47 +60,79 @@ function DeleteAllUnusedTags({ numUnusedTags }: { numUnusedTags: number }) {
);
}

function TagPill({
id,
name,
count,
}: {
id: string;
name: string;
count: number;
}) {
return (
<div className="group relative flex">
<Link
className="flex gap-2 rounded-md border border-border bg-background px-2 py-1 text-foreground hover:bg-foreground hover:text-background"
href={`/dashboard/tags/${id}`}
>
{name} <Separator orientation="vertical" /> {count}
</Link>

<DeleteTagConfirmationDialog tag={{ name, id }}>
<Button
size="none"
variant="secondary"
className="-translate-1/2 absolute -right-1 -top-1 hidden rounded-full group-hover:block"
>
<X className="size-3" />
</Button>
</DeleteTagConfirmationDialog>
</div>
);
}
const byUsageSorter = (a: ZGetTagResponse, b: ZGetTagResponse) => {
// Sort by name if the usage is the same to get a stable result
if (b.count == a.count) {
return byNameSorter(a, b);
}
return b.count - a.count;
};
const byNameSorter = (a: ZGetTagResponse, b: ZGetTagResponse) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" });

export default function AllTagsView({
initialData,
}: {
initialData: ZGetTagResponse[];
}) {
const [draggingEnabled, toggleDraggingEnabled] = React.useState(false);
const [sortByName, toggleSortByName] = React.useState(false);

const { dragState, handleDrag, handleDragStart, handleDragEnd } =
useDragAndDrop(
"data-id",
"data-id",
(dragSourceId: string, dragTargetId: string) => {
mergeTag({
fromTagIds: [dragSourceId],
intoTagId: dragTargetId,
});
},
);

function handleSortByNameChange(): void {
toggleSortByName(!sortByName);
}

function handleDraggableChange(): void {
toggleDraggingEnabled(!draggingEnabled);
}

const { mutate: mergeTag } = useMergeTag({
onSuccess: () => {
toast({
description: "Tags have been merged!",
});
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
if (e.data.zodError) {
toast({
variant: "destructive",
description: Object.values(e.data.zodError.fieldErrors)
.flat()
.join("\n"),
});
} else {
toast({
variant: "destructive",
description: e.message,
});
}
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});

const { data } = api.tags.list.useQuery(undefined, {
initialData: { tags: initialData },
});
// Sort tags by usage desc
const allTags = data.tags.sort((a, b) => b.count - a.count);
const allTags = data.tags.sort(sortByName ? byNameSorter : byUsageSorter);

const humanTags = allTags.filter((t) => (t.countAttachedBy.human ?? 0) > 0);
const aiTags = allTags.filter((t) => (t.countAttachedBy.ai ?? 0) > 0);
Expand All @@ -104,23 +141,73 @@ export default function AllTagsView({
const tagsToPill = (tags: typeof allTags) => {
let tagPill;
if (tags.length) {
tagPill = tags.map((t) => (
<TagPill key={t.id} id={t.id} name={t.name} count={t.count} />
));
tagPill = (
<div className="flex flex-wrap gap-3">
{tags.map((t) => (
<Draggable
key={t.id}
axis="both"
onStart={handleDragStart}
onDrag={handleDrag}
onStop={handleDragEnd}
disabled={!draggingEnabled}
defaultClassNameDragging={
"position-relative z-10 pointer-events-none"
}
position={
!dragState.dragSourceId
? {
x: dragState.initialX ?? 0,
y: dragState.initialY ?? 0,
}
: undefined
}
>
<div className="group relative flex cursor-grab" data-id={t.id}>
<TagPill
id={t.id}
name={t.name}
count={t.count}
isDraggable={draggingEnabled}
/>
</div>
</Draggable>
))}
</div>
);
} else {
tagPill = "No Tags";
}
return tagPill;
};
return (
<>
<div className="float-right">
<Toggle
variant="outline"
aria-label="Toggle bold"
pressed={draggingEnabled}
onPressedChange={handleDraggableChange}
>
Allow Merging via Drag&Drop
</Toggle>
<Toggle
variant="outline"
aria-label="Toggle bold"
pressed={sortByName}
onPressedChange={handleSortByNameChange}
>
Sort by Name
</Toggle>
</div>
<span className="flex items-center gap-2">
<p className="text-lg">Your Tags</p>
<InfoTooltip size={15} className="my-auto" variant="explain">
<p>Tags that were attached at least once by you</p>
</InfoTooltip>
</span>
<div className="flex flex-wrap gap-3">{tagsToPill(humanTags)}</div>

{tagsToPill(humanTags)}

<Separator />

Expand All @@ -130,7 +217,8 @@ export default function AllTagsView({
<p>Tags that were only attached automatically (by AI)</p>
</InfoTooltip>
</span>
<div className="flex flex-wrap gap-3">{tagsToPill(aiTags)}</div>

{tagsToPill(aiTags)}

<Separator />

Expand All @@ -153,9 +241,7 @@ export default function AllTagsView({
<DeleteAllUnusedTags numUnusedTags={emptyTags.length} />
)}
</div>
<CollapsibleContent>
<div className="flex flex-wrap gap-3">{tagsToPill(emptyTags)}</div>
</CollapsibleContent>
<CollapsibleContent>{tagsToPill(emptyTags)}</CollapsibleContent>
</Collapsible>
</>
);
Expand Down
48 changes: 48 additions & 0 deletions apps/web/components/dashboard/tags/TagPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { X } from "lucide-react";

import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog";

const PILL_STYLE =
"flex gap-2 rounded-md border border-border bg-background px-2 py-1 text-foreground hover:bg-foreground hover:text-background";

export function TagPill({
id,
name,
count,
isDraggable,
}: {
id: string;
name: string;
count: number;
isDraggable: boolean;
}) {
// When the element is draggable, do not generate a link. Links can be dragged into e.g. the tab-bar and therefore dragging the TagPill does not work properly
if (isDraggable) {
return (
<div className={PILL_STYLE} data-id={id}>
{name} <Separator orientation="vertical" /> {count}
</div>
);
}

return (
<div className="group relative flex">
<Link className={PILL_STYLE} href={`/dashboard/tags/${id}`}>
{name} <Separator orientation="vertical" /> {count}
</Link>

<DeleteTagConfirmationDialog tag={{ name, id }}>
<Button
size="none"
variant="secondary"
className="-translate-1/2 absolute -right-1 -top-1 hidden rounded-full group-hover:block"
>
<X className="size-3" />
</Button>
</DeleteTagConfirmationDialog>
</div>
);
}
45 changes: 45 additions & 0 deletions apps/web/components/ui/toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import type { VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva } from "class-variance-authority";

const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);

const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));

Toggle.displayName = TogglePrimitive.Root.displayName;

export { Toggle, toggleVariants };
Loading

0 comments on commit 1fee129

Please sign in to comment.