diff options
| -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 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 52 |
6 files changed, 365 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8da19af4..8ab67bfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -493,6 +493,9 @@ importers: '@radix-ui/react-toast': specifier: ^1.1.5 version: 1.1.5(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-toggle': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-tooltip': specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -556,6 +559,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-draggable: + specifier: ^4.4.6 + version: 4.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-dropzone: specifier: ^14.2.3 version: 14.2.3(react@18.2.0) @@ -3332,6 +3338,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-toggle@1.0.3': + resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.0.7': resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==} peerDependencies: @@ -5301,6 +5320,10 @@ packages: resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} engines: {node: '>=0.8'} + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} @@ -10144,6 +10167,12 @@ packages: peerDependencies: react: ^18.2.0 + react-draggable@4.4.6: + resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-dropzone@14.2.3: resolution: {integrity: sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==} engines: {node: '>= 10.13'} @@ -16655,6 +16684,18 @@ snapshots: react-dom: 18.2.0(react@18.2.0) dev: false + '@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.58)(react@18.2.0) + '@types/react': 18.2.58 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + '@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.23.9 @@ -19664,6 +19705,9 @@ snapshots: clone@2.1.2: dev: false + clsx@1.2.1: + dev: false + clsx@2.0.0: dev: false @@ -26245,6 +26289,14 @@ snapshots: react: 18.2.0 scheduler: 0.23.0 + react-draggable@4.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + clsx: 1.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + react-dropzone@14.2.3(react@18.2.0): dependencies: attr-accept: 2.2.2 |
