diff options
| author | MohamedBassem <me@mbassem.com> | 2024-04-26 10:35:36 +0100 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-04-26 10:35:36 +0100 |
| commit | 9dace185acff4002aec8265fc010db49d91c7d7f (patch) | |
| tree | 24ea7eaee7e99a689c6b51912e30993c74eb97dc /apps | |
| parent | d07f2c90065f53d36a3fc0e7db54c32d54a2a332 (diff) | |
| download | karakeep-9dace185acff4002aec8265fc010db49d91c7d7f.tar.zst | |
feature: A new cleanups page to suggest ways to tidy up your bookmarks
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/dashboard/cleanups/page.tsx | 21 | ||||
| -rw-r--r-- | apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx | 280 | ||||
| -rw-r--r-- | apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx | 8 | ||||
| -rw-r--r-- | apps/web/next.config.mjs | 4 | ||||
| -rw-r--r-- | apps/web/package.json | 1 |
5 files changed, 314 insertions, 0 deletions
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 ( + <div className="flex flex-col gap-y-4 rounded-md border bg-background p-4"> + <span className="flex items-center gap-1 text-2xl"> + <Paintbrush /> + Cleanups + </span> + <Separator /> + <span className="flex items-center gap-1 text-xl"> + <Tags /> + Duplicate Tags + </span> + <Separator /> + <TagDuplicationDetection /> + </div> + ); +} 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<Suggestion[]>([]); + + 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 ( + <ActionConfirmingDialog + title="Merge all suggestions?" + description={`Are you sure you want to apply all ${suggestions.length} suggestions?`} + actionButton={(setDialogOpen) => ( + <ActionButton + loading={applying} + variant="destructive" + onClick={() => applyAll(setDialogOpen)} + > + <Check className="mr-2 size-4" /> + Apply All + </ActionButton> + )} + > + <Button variant="destructive"> + <Check className="mr-2 size-4" /> + Apply All + </Button> + </ActionConfirmingDialog> + ); +} + +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 ( + <TableRow key={suggestion.mergeIntoId}> + <TableCell className="flex flex-wrap gap-1"> + {suggestion.tags.map((tag, idx) => { + const selected = suggestion.mergeIntoId == tag.id; + return ( + <div key={idx} className="group relative"> + <Link + href={`/dashboard/tags/${tag.id}`} + className={cn( + badgeVariants({ variant: "outline" }), + "text-sm", + selected + ? "border border-blue-500 dark:border-blue-900" + : null, + )} + > + {tag.name} + </Link> + <Button + size="none" + className={cn( + "-translate-1/2 absolute -right-1.5 -top-1.5 rounded-full p-0.5", + selected ? null : "hidden group-hover:block", + )} + onClick={() => updateMergeInto(suggestion, tag.id)} + > + <Check className="size-3" /> + </Button> + </div> + ); + })} + </TableCell> + <TableCell className="space-x-1 space-y-1 text-center"> + <ActionButton + loading={isPending} + onClick={() => + mutate({ + intoTagId: suggestion.mergeIntoId, + fromTagIds: suggestion.tags + .filter((t) => t.id != suggestion.mergeIntoId) + .map((t) => t.id), + }) + } + > + <Combine className="mr-2 size-4" /> + Merge + </ActionButton> + + <Button + variant={"secondary"} + onClick={() => deleteSuggestion(suggestion)} + > + <X className="mr-2 size-4" /> + Ignore + </Button> + </TableCell> + </TableRow> + ); +} + +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 <LoadingSpinner />; + } + + return ( + <Collapsible open={expanded} onOpenChange={setExpanded}> + You have {suggestions.length} suggestions for tag merging. + {suggestions.length > 0 && ( + <CollapsibleTrigger asChild> + <Button variant="link" size="sm"> + {expanded ? "Hide All" : "Show All"} + </Button> + </CollapsibleTrigger> + )} + <CollapsibleContent> + <p className="text-sm italic text-muted-foreground"> + For every suggestion, select the tag that you want to keep and other + tags will be merged into it. + </p> + {suggestions.length > 0 && ( + <Table> + <TableHeader> + <TableRow> + <TableHead>Tags</TableHead> + <TableHead className="text-center"> + <ApplyAllButton suggestions={suggestions} /> + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {suggestions.map((suggestion) => ( + <SuggestionRow + key={suggestion.mergeIntoId} + suggestion={suggestion} + updateMergeInto={updateMergeInto} + deleteSuggestion={deleteSuggestion} + /> + ))} + </TableBody> + </Table> + )} + </CollapsibleContent> + </Collapsible> + ); +} 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() { </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-fit"> + <DropdownMenuItem asChild> + <Link href="/dashboard/cleanups"> + <Paintbrush className="mr-2 size-4" /> + Cleanups + </Link> + </DropdownMenuItem> <DropdownMenuItem onClick={toggleTheme}> <DarkModeToggle /> </DropdownMenuItem> 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", |
