diff options
| author | kamtschatka <sschatka@gmail.com> | 2024-05-18 12:22:50 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-05-18 11:22:50 +0100 |
| commit | 1fee129c337069fc41d1c46141beaaec94033af8 (patch) | |
| tree | 1eec8b4f971731b6c24fafd93f7fc6adf881f32c /apps/web | |
| parent | 6eea67186c2fa5f5c5e155c7fe17f63a0114157f (diff) | |
| download | karakeep-1fee129c337069fc41d1c46141beaaec94033af8.tar.zst | |
feature(web): Improve merging of tags by simple drag and drop #144 (#154)
* 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>
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/components/dashboard/tags/AllTagsView.tsx | 172 | ||||
| -rw-r--r-- | apps/web/components/dashboard/tags/TagPill.tsx | 48 | ||||
| -rw-r--r-- | apps/web/components/ui/toggle.tsx | 45 | ||||
| -rw-r--r-- | apps/web/lib/drag-and-drop.ts | 89 | ||||
| -rw-r--r-- | apps/web/package.json | 2 |
5 files changed, 313 insertions, 43 deletions
diff --git a/apps/web/components/dashboard/tags/AllTagsView.tsx b/apps/web/components/dashboard/tags/AllTagsView.tsx index a16dd759..017a1e40 100644 --- a/apps/web/components/dashboard/tags/AllTagsView.tsx +++ b/apps/web/components/dashboard/tags/AllTagsView.tsx @@ -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"; @@ -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({ @@ -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); @@ -104,9 +141,40 @@ 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"; } @@ -114,13 +182,32 @@ export default function AllTagsView({ }; 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 /> @@ -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 /> @@ -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> </> ); diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx new file mode 100644 index 00000000..7236dc39 --- /dev/null +++ b/apps/web/components/dashboard/tags/TagPill.tsx @@ -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> + ); +} diff --git a/apps/web/components/ui/toggle.tsx b/apps/web/components/ui/toggle.tsx new file mode 100644 index 00000000..a5834cf9 --- /dev/null +++ b/apps/web/components/ui/toggle.tsx @@ -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 }; diff --git a/apps/web/lib/drag-and-drop.ts b/apps/web/lib/drag-and-drop.ts new file mode 100644 index 00000000..03a9e7c3 --- /dev/null +++ b/apps/web/lib/drag-and-drop.ts @@ -0,0 +1,89 @@ +import { DraggableData, DraggableEvent } from "react-draggable"; + +export interface DragState { + // The id of the element that is being dragged + dragSourceId: string | null; + // The id of the element that is currently being hovered over + dragTargetId: string | null; + // The position of the elements being dragged such that on drag over, we can revert the position. + initialX: number; + initialY: number; +} + +export interface DragAndDropFunctions { + handleDragStart: (e: DraggableEvent, data: DraggableData) => void; + handleDrag: (e: DraggableEvent) => void; + handleDragEnd: () => void; + dragState: DragState; +} + +export function useDragAndDrop( + dragSourceIdAttribute: string, + dragTargetIdAttribute: string, + callback: (dragSourceId: string, dragTargetId: string) => void, +): DragAndDropFunctions { + const initialState: DragState = { + dragSourceId: null, + dragTargetId: null, + initialX: 0, + initialY: 0, + }; + + let currentState: DragState = initialState; + + function handleDragStart(e: DraggableEvent, data: DraggableData): void { + const { node } = data; + const id = node.getAttribute(dragSourceIdAttribute); + + currentState = { + ...initialState, + dragSourceId: id, + initialX: data.x, + initialY: data.y, + }; + } + + function handleDrag(e: DraggableEvent): void { + const { dragTargetId } = currentState; + const { target } = e; + + // Important according to the sample I found + e.preventDefault(); + + if (target) { + const id = (target as HTMLElement).getAttribute(dragTargetIdAttribute); + + if (id !== dragTargetId) { + currentState.dragTargetId = id; + } + } + } + + function handleDragEnd(): void { + const { dragSourceId, dragTargetId } = currentState; + + if (dragSourceId && dragTargetId && dragSourceId !== dragTargetId) { + /* + As Draggable tries to setState when the + component is unmounted, it is needed to + push onCombine to the event loop queue. + onCombine would be run after setState on + Draggable so it would fix the issue until + they fix it on their end. + */ + setTimeout(() => { + console.log(dragSourceId, dragTargetId); + callback(dragSourceId, dragTargetId); + }, 0); + } + + currentState = initialState; + } + + return { + dragState: currentState, + handleDragStart, + handleDrag, + handleDragEnd, + }; +} diff --git a/apps/web/package.json b/apps/web/package.json index f172b50d..ebec0278 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", "@svgr/webpack": "^8.1.0", "@tanstack/react-query": "^5.24.8", @@ -55,6 +56,7 @@ "prettier": "^3.2.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-draggable": "^4.4.6", "react-dropzone": "^14.2.3", "react-hook-form": "^7.50.1", "react-markdown": "^9.0.1", |
