From 9dace185acff4002aec8265fc010db49d91c7d7f Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Fri, 26 Apr 2024 10:35:36 +0100 Subject: feature: A new cleanups page to suggest ways to tidy up your bookmarks --- apps/web/app/dashboard/cleanups/page.tsx | 21 ++ .../dashboard/cleanups/TagDuplicationDetention.tsx | 280 +++++++++++++++++++++ .../dashboard/sidebar/SidebarProfileOptions.tsx | 8 + apps/web/next.config.mjs | 4 + apps/web/package.json | 1 + 5 files changed, 314 insertions(+) create mode 100644 apps/web/app/dashboard/cleanups/page.tsx create mode 100644 apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx (limited to 'apps') diff --git a/apps/web/app/dashboard/cleanups/page.tsx b/apps/web/app/dashboard/cleanups/page.tsx new file mode 100644 index 00000000..ca9187ee --- /dev/null +++ b/apps/web/app/dashboard/cleanups/page.tsx @@ -0,0 +1,21 @@ +import { TagDuplicationDetection } from "@/components/dashboard/cleanups/TagDuplicationDetention"; +import { Separator } from "@/components/ui/separator"; +import { Paintbrush, Tags } from "lucide-react"; + +export default function Cleanups() { + return ( +
+ + + Cleanups + + + + + Duplicate Tags + + + +
+ ); +} diff --git a/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx new file mode 100644 index 00000000..619158fd --- /dev/null +++ b/apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { badgeVariants } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import { distance } from "fastest-levenshtein"; +import { Check, Combine, X } from "lucide-react"; + +import { useMergeTag } from "@hoarder/shared-react/hooks/tags"; + +interface Suggestion { + mergeIntoId: string; + tags: { id: string; name: string }[]; +} + +function normalizeTag(tag: string) { + return tag.toLocaleLowerCase().replace(/[ -]/g, ""); +} + +const useSuggestions = () => { + const [suggestions, setSuggestions] = useState([]); + + function updateMergeInto(suggestion: Suggestion, newMergeIntoId: string) { + setSuggestions((prev) => + prev.map((s) => + s === suggestion ? { ...s, mergeIntoId: newMergeIntoId } : s, + ), + ); + } + + function deleteSuggestion(suggestion: Suggestion) { + setSuggestions((prev) => prev.filter((s) => s !== suggestion)); + } + + return { suggestions, updateMergeInto, deleteSuggestion, setSuggestions }; +}; + +function ApplyAllButton({ suggestions }: { suggestions: Suggestion[] }) { + const [applying, setApplying] = useState(false); + const { mutateAsync } = useMergeTag({ + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + + const applyAll = async (setDialogOpen: (open: boolean) => void) => { + const promises = suggestions.map((suggestion) => + mutateAsync({ + intoTagId: suggestion.mergeIntoId, + fromTagIds: suggestion.tags + .filter((t) => t.id != suggestion.mergeIntoId) + .map((t) => t.id), + }), + ); + setApplying(true); + await Promise.all(promises) + .then(() => { + toast({ + description: "All suggestions has been applied!", + }); + }) + .catch(() => ({})) + .finally(() => { + setApplying(false); + setDialogOpen(false); + }); + }; + + return ( + ( + applyAll(setDialogOpen)} + > + + Apply All + + )} + > + + + ); +} + +function SuggestionRow({ + suggestion, + updateMergeInto, + deleteSuggestion, +}: { + suggestion: Suggestion; + updateMergeInto: (suggestion: Suggestion, newMergeIntoId: string) => void; + deleteSuggestion: (suggestion: Suggestion) => void; +}) { + const { mutate, isPending } = useMergeTag({ + onSuccess: () => { + toast({ + description: "Tags have been merged!", + }); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + return ( + + + {suggestion.tags.map((tag, idx) => { + const selected = suggestion.mergeIntoId == tag.id; + return ( +
+ + {tag.name} + + +
+ ); + })} +
+ + + mutate({ + intoTagId: suggestion.mergeIntoId, + fromTagIds: suggestion.tags + .filter((t) => t.id != suggestion.mergeIntoId) + .map((t) => t.id), + }) + } + > + + Merge + + + + +
+ ); +} + +export function TagDuplicationDetection() { + const [expanded, setExpanded] = useState(false); + let { data: allTags } = api.tags.list.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + + const { suggestions, updateMergeInto, setSuggestions, deleteSuggestion } = + useSuggestions(); + + useEffect(() => { + allTags = allTags ?? { tags: [] }; + const sortedTags = allTags.tags.sort((a, b) => + normalizeTag(a.name).localeCompare(normalizeTag(b.name)), + ); + + const initialSuggestions: Suggestion[] = []; + for (let i = 0; i < sortedTags.length; i++) { + const currentName = normalizeTag(sortedTags[i].name); + const suggestion = [sortedTags[i]]; + for (let j = i + 1; j < sortedTags.length; j++) { + const nextName = normalizeTag(sortedTags[j].name); + if (distance(currentName, nextName) <= 1) { + suggestion.push(sortedTags[j]); + } else { + break; + } + } + if (suggestion.length > 1) { + initialSuggestions.push({ + mergeIntoId: suggestion[0].id, + tags: suggestion, + }); + i += suggestion.length - 1; + } + } + setSuggestions(initialSuggestions); + }, [allTags]); + + if (!allTags) { + return ; + } + + return ( + + You have {suggestions.length} suggestions for tag merging. + {suggestions.length > 0 && ( + + + + )} + +

+ For every suggestion, select the tag that you want to keep and other + tags will be merged into it. +

+ {suggestions.length > 0 && ( + + + + Tags + + + + + + + {suggestions.map((suggestion) => ( + + ))} + +
+ )} +
+
+ ); +} diff --git a/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx b/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx index c75e292a..c2ae493a 100644 --- a/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx +++ b/apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useToggleTheme } from "@/components/theme-provider"; import { Button } from "@/components/ui/button"; import { @@ -22,6 +23,7 @@ import { LogOut, Moon, MoreHorizontal, + Paintbrush, Sun, } from "lucide-react"; import { signOut } from "next-auth/react"; @@ -98,6 +100,12 @@ export default function SidebarProfileOptions() { + + + + Cleanups + + diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 433f0c9d..7384f0b1 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -15,6 +15,10 @@ const nextConfig = withPWA({ }); return config; }, + devIndicators: { + buildActivity: true, + buildActivityPosition: "bottom-left", + }, async headers() { return [ { diff --git a/apps/web/package.json b/apps/web/package.json index 8f35e0ad..f172b50d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,6 +46,7 @@ "clsx": "^2.1.0", "dayjs": "^1.11.10", "drizzle-orm": "^0.29.4", + "fastest-levenshtein": "^1.0.16", "lucide-react": "^0.330.0", "next": "14.1.4", "next-auth": "^4.24.5", -- cgit v1.2.3-70-g09d2