aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-04-26 10:35:36 +0100
committerMohamedBassem <me@mbassem.com>2024-04-26 10:35:36 +0100
commit9dace185acff4002aec8265fc010db49d91c7d7f (patch)
tree24ea7eaee7e99a689c6b51912e30993c74eb97dc /apps
parentd07f2c90065f53d36a3fc0e7db54c32d54a2a332 (diff)
downloadkarakeep-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.tsx21
-rw-r--r--apps/web/components/dashboard/cleanups/TagDuplicationDetention.tsx280
-rw-r--r--apps/web/components/dashboard/sidebar/SidebarProfileOptions.tsx8
-rw-r--r--apps/web/next.config.mjs4
-rw-r--r--apps/web/package.json1
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",