many fixes and improvements

This commit is contained in:
daniel31x13
2025-09-17 19:47:57 -04:00
parent 395f5c01e1
commit f4d3b8f657
18 changed files with 658 additions and 225 deletions

View File

@@ -79,7 +79,7 @@ export default function CollectionCard({
size="icon"
className="absolute top-3 right-3 z-20"
>
<i title="More" className="bi-three-dots text-xl" />
<i title="More" className="bi-three-dots text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>

View File

@@ -1,113 +1,72 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import TextInput from "@/components/TextInput";
import toast from "react-hot-toast";
import React from "react";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import toast from "react-hot-toast";
import { Separator } from "../ui/separator";
import { useBulkTagDeletion, useUpsertTags } from "@linkwarden/router/tags";
import { Trans, useTranslation } from "next-i18next";
import { useBulkTagDeletion } from "@linkwarden/router/tags";
type Props = {
onClose: Function;
selectedTags: number[];
setSelectedTags: (tags: number[]) => void;
};
export default function BulkDeleteTagsModal({ onClose }: Props) {
export default function BulkDeleteTagsModal({
onClose,
selectedTags,
setSelectedTags,
}: Props) {
const { t } = useTranslation();
const [numberOfLinks, setNumberOfLinks] = useState(0);
const bulkDeleteTags = useBulkTagDeletion();
const deleteTagsById = useBulkTagDeletion();
const deleteTag = async () => {
const load = toast.loading(t("deleting"));
await deleteTagsById.mutateAsync(
{
tagIds: selectedTags,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedTags([]);
onClose();
toast.success(t("deleted"));
}
},
}
);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("bulk_delete_tags")}</p>
<p className="text-xl font-thin text-red-500">
{selectedTags.length === 1
? t("delete_tag")
: t("delete_tags", { count: selectedTags.length })}
</p>
<Separator className="my-3" />
<div className="flex flex-col gap-3">
<div className="flex justify-between flex-wrap">
<div className="flex gap-2 items-center">
<Trans
i18nKey="delete_tags_by_number_of_links"
components={[
<TextInput
value={numberOfLinks}
onChange={(e) => setNumberOfLinks(Number(e.target.value))}
type="number"
min={0}
className="bg-base-200 max-w-12 h-10"
/>,
]}
/>
</div>
<p>
{selectedTags.length === 1
? t("tag_deletion_confirmation_message")
: t("tags_deletion_confirmation_message", {
count: selectedTags.length,
})}
</p>
<Button
variant="destructive"
onClick={() => {
bulkDeleteTags.mutate(
{ numberOfLinks: Number(numberOfLinks) },
{
onSuccess: (data: number) => {
if (data === 1) {
toast.success(
t("count_tag_deleted", {
count: data,
})
);
} else {
toast.success(
t("count_tags_deleted", {
count: data,
})
);
}
onClose();
},
onError: () => {
toast.error(t("error_deleting_tags"));
},
}
);
}}
>
{t("delete")}
</Button>
</div>
<div className="flex justify-between flex-wrap">
<div className="flex gap-2 items-center">{t("delete_all_tags")}</div>
<Button
variant="destructive"
className="capitalize"
onClick={() => {
bulkDeleteTags.mutate(
{ allTags: true },
{
onSuccess: (data: number) => {
if (data === 1) {
toast.success(
t("count_tag_deleted", {
count: data,
})
);
} else {
toast.success(
t("count_tags_deleted", {
count: data,
})
);
}
onClose();
},
onError: () => {
toast.error(t("error_deleting_tags"));
},
}
);
}}
>
{t("delete_all_tags")}
</Button>
</div>
<Button className="ml-auto" variant="destructive" onClick={deleteTag}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);

View File

@@ -0,0 +1,75 @@
import React, { useState } from "react";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import toast from "react-hot-toast";
import { Separator } from "../ui/separator";
import { useMergeTags } from "@linkwarden/router/tags";
import TextInput from "../TextInput";
type Props = {
onClose: Function;
selectedTags: number[];
setSelectedTags: (tags: number[]) => void;
};
export default function MergeTagsModal({
onClose,
selectedTags,
setSelectedTags,
}: Props) {
const { t } = useTranslation();
const [newTagName, setNewTagName] = useState("");
const mergeTags = useMergeTags();
const merge = async () => {
const load = toast.loading(t("merging"));
await mergeTags.mutateAsync(
{
tagIds: selectedTags,
newTagName,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedTags([]);
onClose();
toast.success(t("deleted"));
}
},
}
);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{t("merge_count_tags", { count: selectedTags.length })}
</p>
<Separator className="my-3" />
<div className="flex flex-col gap-3">
<p>{t("rename_tag_instruction")}</p>
<TextInput
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
placeholder={t("tag_name_placeholder")}
/>
<Button className="ml-auto" variant="accent" onClick={merge}>
<i className="bi-intersect text-xl" />
{t("merge_tags")}
</Button>
</div>
</Modal>
);
}

View File

@@ -63,6 +63,7 @@ export default function NewTagModal({ onClose }: Props) {
value={tag.label}
onChange={(e) => setTag({ ...tag, label: e.target.value })}
className="bg-base-200"
placeholder={t("tag_name_placeholder")}
/>
</div>

View File

@@ -1,4 +1,3 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Disclosure, Transition } from "@headlessui/react";
@@ -60,25 +59,25 @@ export default function Sidebar({ className }: { className?: string }) {
active={active === `/dashboard`}
/>
<SidebarHighlightLink
title={t("all_links")}
title={t("links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
/>
<SidebarHighlightLink
title={t("pinned_links")}
title={t("pinned")}
href={`/links/pinned`}
icon={"bi-pin-angle"}
active={active === `/links/pinned`}
/>
<SidebarHighlightLink
title={t("all_collections")}
title={t("collections")}
href={`/collections`}
icon={"bi-folder"}
active={active === `/collections`}
/>
<SidebarHighlightLink
title={t("all_tags")}
title={t("tags")}
href={`/tags`}
icon={"bi-hash"}
active={active === `/tags`}

View File

@@ -8,9 +8,22 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { TagIncludingLinkCount } from "@linkwarden/types";
import DeleteTagModal from "./ModalContent/DeleteTagModal";
export default function TagCard({ tag }: { tag: TagIncludingLinkCount }) {
import { cn } from "@/lib/utils";
import { useRouter } from "next/router";
export default function TagCard({
tag,
editMode,
selected,
onSelect,
}: {
tag: TagIncludingLinkCount;
editMode: boolean;
selected: boolean;
onSelect: (tagId: number) => void;
}) {
const { t } = useTranslation();
const formattedDate = new Date(tag.createdAt).toLocaleString(t("locale"), {
@@ -21,35 +34,52 @@ export default function TagCard({ tag }: { tag: TagIncludingLinkCount }) {
const [deleteTagModal, setDeleteTagModal] = useState(false);
const router = useRouter();
return (
<div className="relative rounded-xl p-2 flex gap-2 flex-col bg-base-200 shadow-md hover:shadow-none duration-200 border border-neutral-content">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 z-20"
>
<i title="More" className="bi-three-dots text-xl" />
</Button>
</DropdownMenuTrigger>
<div
className={cn(
"relative rounded-xl p-2 flex gap-2 flex-col shadow-md cursor-pointer hover:shadow-none hover:bg-opacity-70 duration-200 border border-neutral-content",
editMode ? "bg-base-300" : "bg-base-200",
selected && "border-primary"
)}
onClick={() =>
editMode ? onSelect(tag.id) : router.push(`/tags/${tag.id}`)
}
>
{editMode ? (
<Checkbox checked={selected} className="absolute top-3 right-3 z-20" />
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 z-20"
onClick={(e) => e.stopPropagation()}
>
<i title="More" className="bi-three-dots text-xl text-neutral" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={4}
side="bottom"
align="end"
className="z-[30]"
>
<DropdownMenuItem
onSelect={() => setDeleteTagModal(true)}
className="text-error"
<DropdownMenuContent
sideOffset={4}
side="bottom"
align="end"
className="z-[30]"
onClick={(e) => e.stopPropagation()}
>
{t("delete_tag")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenuItem
onSelect={() => setDeleteTagModal(true)}
className="text-error"
>
{t("delete_tag")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<h2 className="text-lg truncate leading-tight py-1" title={tag.name}>
<h2 className="truncate leading-tight py-1 pr-8" title={tag.name}>
{tag.name}
</h2>

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -19,74 +19,36 @@ export default async function bulkTagDelete(
};
}
const { numberOfLinks, allTags } = dataValidation.data;
const { tagIds } = dataValidation.data;
let deletedTag: number;
let affectedLinks: number[];
if (allTags) {
affectedLinks = (
await prisma.link.findMany({
where: {
tags: {
some: {
ownerId: userId,
},
affectedLinks = (
await prisma.link.findMany({
where: {
tags: {
some: {
ownerId: userId,
},
},
select: {
id: true,
},
})
).map((link) => link.id);
deletedTag = (
await prisma.tag.deleteMany({
where: {
ownerId: userId,
},
})
).count;
} else {
const tags = await prisma.tag.findMany({
where: {
ownerId: userId,
},
select: {
id: true,
links: {
select: {
id: true,
},
},
_count: {
select: {
links: true,
},
},
})
).map((link) => link.id);
deletedTag = (
await prisma.tag.deleteMany({
where: {
ownerId: userId,
id: {
in: tagIds,
},
},
});
const tagsToDelete = tags
.filter((tag) => tag._count.links === (numberOfLinks ?? 0))
.map((tag) => tag.id);
const links = tags
.filter((tag) => tag._count.links === (numberOfLinks ?? 0))
.map((tag) => tag.links);
affectedLinks = links.flat().map((link) => link.id);
deletedTag = (
await prisma.tag.deleteMany({
where: {
id: {
in: tagsToDelete,
},
},
})
).count;
}
})
).count;
await prisma.link.updateMany({
where: {

View File

@@ -0,0 +1,76 @@
import {
MergeTagsSchema,
MergeTagsSchemaType,
} from "@linkwarden/lib/schemaValidation";
import { prisma } from "@linkwarden/prisma";
export default async function mergeTags(
userId: number,
body: MergeTagsSchemaType
) {
const dataValidation = MergeTagsSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const { tagIds, newTagName } = dataValidation.data;
let affectedLinks: number[];
affectedLinks = (
await prisma.link.findMany({
where: {
tags: {
some: {
ownerId: userId,
},
},
},
select: {
id: true,
},
})
).map((link) => link.id);
const { newTag } = await prisma.$transaction(async (tx) => {
await tx.tag.deleteMany({
where: {
ownerId: userId,
id: {
in: tagIds,
},
},
});
const newTag = await tx.tag.create({
data: {
name: newTagName,
ownerId: userId,
links: {
connect: affectedLinks.map((id) => ({ id })),
},
},
});
await tx.link.updateMany({
where: {
id: {
in: affectedLinks,
},
},
data: {
indexVersion: null,
},
});
return { newTag };
});
return { response: newTag, status: 200 };
}

View File

@@ -30,6 +30,7 @@
"@linkwarden/types": "*",
"@phosphor-icons/core": "^2.1.1",
"@phosphor-icons/react": "^2.1.7",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",

View File

@@ -3,6 +3,7 @@ import getTags from "@/lib/api/controllers/tags/getTags";
import verifyUser from "@/lib/api/verifyUser";
import { PostTagSchema } from "@linkwarden/lib/schemaValidation";
import createOrUpdateTags from "@/lib/api/controllers/tags/createOrUpdateTags";
import bulkTagDelete from "@/lib/api/controllers/tags/bulkTagDelete";
export default async function tags(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
@@ -39,4 +40,15 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ response: newOrUpdatedTags });
}
if (req.method === "DELETE") {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const tags = await bulkTagDelete(user.id, req.body);
return res.status(tags.status).json({ response: tags.response });
}
}

View File

@@ -1,22 +1,19 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import bulkTagDelete from "@/lib/api/controllers/tags/bulkTagDelete";
import mergeTags from "@/lib/api/controllers/tags/mergeTags";
export default async function bulkDelete(
req: NextApiRequest,
res: NextApiResponse
) {
export default async function merge(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "DELETE") {
if (req.method === "PUT") {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const tags = await bulkTagDelete(user.id, req.body);
const tags = await mergeTags(user.id, req.body);
return res.status(tags.status).json({ response: tags.response });
}
}

View File

@@ -1,10 +1,9 @@
import CollectionCard from "@/components/CollectionCard";
import { useState } from "react";
import { useMemo, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
import { useSession } from "next-auth/react";
import SortDropdown from "@/components/SortDropdown";
import { Sort } from "@linkwarden/types";
import useSort from "@/hooks/useSort";
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
import PageHeader from "@/components/PageHeader";
import getServerSideProps from "@/lib/client/getServerSideProps";
@@ -21,14 +20,35 @@ import { Button } from "@/components/ui/button";
export default function Collections() {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const [sortBy, setSortBy] = useState<Sort>(
Number(localStorage.getItem("sortBy")) ?? Sort.DateNewestFirst
);
const [sortedCollections, setSortedCollections] = useState(collections);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const { data } = useSession();
useSort({ sortBy, setData: setSortedCollections, data: collections });
const sortKey: Sort =
typeof sortBy === "string" ? (Number(sortBy) as Sort) : sortBy;
const compare = useMemo(() => {
switch (sortKey) {
case Sort.NameAZ:
return (a: any, b: any) => a.name.localeCompare(b.name);
case Sort.NameZA:
return (a: any, b: any) => b.name.localeCompare(a.name);
case Sort.DateOldestFirst:
return (a: any, b: any) =>
new Date(a.createdAt as string).getTime() -
new Date(b.createdAt as string).getTime();
case Sort.DateNewestFirst:
default:
return (a: any, b: any) =>
new Date(b.createdAt as string).getTime() -
new Date(a.createdAt as string).getTime();
}
}, [sortKey]);
const sortedCollections = useMemo(
() => [...collections].sort(compare),
[collections, compare]
);
const [newCollectionModal, setNewCollectionModal] = useState(false);
@@ -70,9 +90,9 @@ export default function Collections() {
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
.map((e, i) => {
return <CollectionCard key={i} collection={e} />;
})}
.map((e) => (
<CollectionCard key={e.id} collection={e} />
))}
<div
className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content p-5 bg-base-200 self-stretch min-h-[12rem] rounded-xl cursor-pointer flex flex-col gap-4 justify-center items-center group"
@@ -96,9 +116,9 @@ export default function Collections() {
<div className="grid 2xl:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections
.filter((e) => e.ownerId !== data?.user.id)
.map((e, i) => {
return <CollectionCard key={i} collection={e} />;
})}
.map((e) => (
<CollectionCard key={e.id} collection={e} />
))}
</div>
</>
)}

View File

@@ -9,24 +9,73 @@ import {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useMemo, useState } from "react";
import NewTagModal from "@/components/ModalContent/NewTagModal";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import BulkDeleteTagsModal from "@/components/ModalContent/BulkDeleteTagsModal";
import MergeTagsModal from "@/components/ModalContent/MergeTagsModal";
enum TagSort {
DateNewestFirst = 0,
DateOldestFirst = 1,
NameAZ = 2,
NameZA = 3,
LinkCountHighLow = 4,
LinkCountLowHigh = 5,
}
export default function Tags() {
const { t } = useTranslation();
const { data: tags = [] } = useTags();
const [sortBy, setSortBy] = useState<TagSort>(TagSort.DateNewestFirst);
const [newTagModal, setNewTagModal] = useState(false);
const [bulkDeleteTagsModal, setBulkDeleteTagsModal] = useState(false);
const [bulkDeleteModal, setBulkDeleteModal] = useState(false);
const [mergeTagsModal, setMergeTagsModal] = useState(false);
const tagTime = (tag: any) => {
if (tag?.createdAt) return new Date(tag.createdAt as string).getTime();
return typeof tag?.id === "number" ? tag.id : 0;
};
const linkCount = (tag: any) =>
tag?.linkCount ?? tag?.linksCount ?? tag?._count?.links ?? 0;
const compare = useMemo(() => {
switch (sortBy) {
case TagSort.NameAZ:
return (a: any, b: any) => (a?.name ?? "").localeCompare(b?.name ?? "");
case TagSort.NameZA:
return (a: any, b: any) => (b?.name ?? "").localeCompare(a?.name ?? "");
case TagSort.DateOldestFirst:
return (a: any, b: any) => tagTime(a) - tagTime(b);
case TagSort.LinkCountHighLow:
return (a: any, b: any) => linkCount(b) - linkCount(a);
case TagSort.LinkCountLowHigh:
return (a: any, b: any) => linkCount(a) - linkCount(b);
case TagSort.DateNewestFirst:
default:
return (a: any, b: any) => tagTime(b) - tagTime(a);
}
}, [sortBy]);
const sortedTags = useMemo(() => tags.slice().sort(compare), [tags, compare]);
const [editMode, setEditMode] = useState(false);
const [selectedTags, setSelectedTags] = useState<number[]>([]);
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full">
<div className="flex justify-between">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<PageHeader icon={"bi-hash"} title={t("tags")} />
<div className="relative">
@@ -41,30 +90,176 @@ export default function Tags() {
<i className="bi-plus-lg" />
{t("new_tag")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setBulkDeleteTagsModal(true)}
>
<i className="bi-trash" />
{t("bulk_delete_tags")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditMode(!editMode);
setSelectedTags([]);
}}
className={editMode ? "bg-primary/20 hover:bg-primary/20" : ""}
>
<i className="bi-pencil-fill text-neutral text-xl" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<i className="bi-chevron-expand text-neutral text-xl"></i>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={4} align="end">
<DropdownMenuRadioGroup
value={sortBy.toString()}
onValueChange={(v) => setSortBy(Number(v) as TagSort)}
>
<DropdownMenuRadioItem
value={TagSort.DateNewestFirst.toString()}
>
{t("date_newest_first")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={TagSort.DateOldestFirst.toString()}
>
{t("date_oldest_first")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={TagSort.NameAZ.toString()}>
{t("name_az")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={TagSort.NameZA.toString()}>
{t("name_za")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={TagSort.LinkCountHighLow.toString()}
>
{t("link_count_high_low")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value={TagSort.LinkCountLowHigh.toString()}
>
{t("link_count_low_high")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{tags && editMode && tags.length > 0 && (
<div className="w-full flex justify-between items-center min-h-[32px]">
<div className="flex gap-3 ml-3">
<input
type="checkbox"
className="checkbox checkbox-primary"
onChange={() => {
if (selectedTags.length === tags.length) setSelectedTags([]);
else setSelectedTags(tags.map((t) => t.id));
}}
checked={selectedTags.length === tags.length && tags.length > 0}
/>
{selectedTags.length > 0 ? (
<span>
{selectedTags.length === 1
? t("tag_selected")
: t("tags_selected", { count: selectedTags.length })}
</span>
) : (
<span>{t("nothing_selected")}</span>
)}
</div>
<div className="flex gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
setMergeTagsModal(true);
}}
variant="ghost"
size="icon"
disabled={selectedTags.length < 2}
>
<i className="bi-intersect" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("merge_tags")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={(e) => {
setBulkDeleteModal(true);
}}
variant="ghost"
size="icon"
disabled={selectedTags.length === 0}
>
<i className="bi-trash text-error" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p> {t("delete")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
)}
<div className="grid 2xl:grid-cols-6 xl:grid-cols-5 sm:grid-cols-3 grid-cols-2 gap-5">
{tags
.slice()
.sort((a, b) => b.id - a.id)
.map((tag) => (
<TagCard key={tag.id} tag={tag} />
))}
{sortedTags.map((tag: any) => (
<TagCard
key={tag.id}
tag={tag}
selected={selectedTags.includes(tag.id)}
editMode={editMode}
onSelect={(id: number) => {
console.log(id);
if (selectedTags.includes(id))
setSelectedTags((prev) => prev.filter((t) => t !== id));
else setSelectedTags((prev) => [...prev, id]);
}}
/>
))}
</div>
</div>
{newTagModal && <NewTagModal onClose={() => setNewTagModal(false)} />}
{bulkDeleteTagsModal && (
<BulkDeleteTagsModal onClose={() => setBulkDeleteTagsModal(false)} />
{bulkDeleteModal && (
<BulkDeleteTagsModal
onClose={() => {
setBulkDeleteModal(false);
setEditMode(false);
}}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
)}
{mergeTagsModal && (
<MergeTagsModal
onClose={() => {
setMergeTagsModal(false);
setEditMode(false);
}}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
)}
</MainLayout>
);

View File

@@ -502,5 +502,16 @@
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags Deleted",
"count_tag_deleted": "{{count}} Tag Deleted"
"count_tag_deleted": "{{count}} Tag Deleted",
"tag_name_placeholder": "e.g. Technology",
"link_count_high_low": "Link count (high to low)",
"link_count_low_high": "Link count (low to high)",
"tags_selected": "{{count}} Tags selected",
"tag_selected": "1 Tag selected",
"merge_tags": "Merge Tags",
"merge_count_tags": "Merge {{count}} Tags",
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
}

View File

@@ -269,12 +269,18 @@ export const PostTagSchema = z.object({
export type PostTagSchemaType = z.infer<typeof PostTagSchema>;
export const TagBulkDeletionSchema = z.object({
numberOfLinks: z.number().min(0).optional(),
allTags: z.boolean().optional(),
tagIds: z.array(z.number()).min(1),
});
export type TagBulkDeletionSchemaType = z.infer<typeof TagBulkDeletionSchema>;
export const MergeTagsSchema = z.object({
newTagName: z.string().trim().max(50),
tagIds: z.array(z.number()).min(1),
});
export type MergeTagsSchemaType = z.infer<typeof MergeTagsSchema>;
export const PostHighlightSchema = z.object({
color: z.string().trim().max(50),
comment: z.string().trim().max(2048).nullish(),

View File

@@ -8,7 +8,10 @@ import { MobileAuth, TagIncludingLinkCount } from "@linkwarden/types";
import { useSession } from "next-auth/react";
import { Tag } from "@linkwarden/prisma/client";
import { ArchivalTagOption } from "@linkwarden/types/inputSelect";
import { TagBulkDeletionSchemaType } from "@linkwarden/lib/schemaValidation";
import {
MergeTagsSchemaType,
TagBulkDeletionSchemaType,
} from "@linkwarden/lib/schemaValidation";
const useTags = (auth?: MobileAuth): UseQueryResult<Tag[], Error> => {
let status: "loading" | "authenticated" | "unauthenticated";
@@ -135,7 +138,7 @@ const useBulkTagDeletion = () => {
return useMutation({
mutationFn: async (body: TagBulkDeletionSchemaType) => {
const response = await fetch(`/api/v1/tags/bulk-delete`, {
const response = await fetch(`/api/v1/tags`, {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
@@ -155,10 +158,36 @@ const useBulkTagDeletion = () => {
});
};
const useMergeTags = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: MergeTagsSchemaType) => {
const response = await fetch(`/api/v1/tags/merge`, {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const responseData = await response.json();
if (!response.ok) throw new Error(responseData.response);
return responseData.response;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["links"] });
},
});
};
export {
useTags,
useUpdateTag,
useUpsertTags,
useRemoveTag,
useBulkTagDeletion,
useMergeTags,
};

View File

@@ -3286,6 +3286,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65"
integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==
"@radix-ui/primitive@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==
"@radix-ui/react-arrow@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.4.tgz#08e263c692b3a56a3f1c4bdc8405b7f73f070963"
@@ -3300,6 +3305,20 @@
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-checkbox@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz#db45ca8a6d5c056a92f74edbb564acee05318b79"
integrity sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-collection@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.4.tgz#45fb4215ca26a84bd61b9b1337105e4d4e01b686"
@@ -3622,6 +3641,14 @@
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-presence@1.1.5":
version "1.1.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db"
integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-primitive@2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz#fe05715faa9203a223ccc0be15dc44b9f9822884"
@@ -3797,6 +3824,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
"@radix-ui/react-use-previous@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5"
integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==
"@radix-ui/react-use-rect@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152"