aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components')
-rw-r--r--apps/web/components/dashboard/tags/AllTagsView.tsx172
-rw-r--r--apps/web/components/dashboard/tags/TagPill.tsx48
-rw-r--r--apps/web/components/ui/toggle.tsx45
3 files changed, 222 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 };