Merge pull request #1406 from linkwarden/feat/tag-management

Feat/tag management
This commit is contained in:
Daniel
2025-09-17 19:48:34 -04:00
committed by GitHub
24 changed files with 1063 additions and 91 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

@@ -0,0 +1,73 @@
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 } from "@linkwarden/router/tags";
type Props = {
onClose: Function;
selectedTags: number[];
setSelectedTags: (tags: number[]) => void;
};
export default function BulkDeleteTagsModal({
onClose,
selectedTags,
setSelectedTags,
}: Props) {
const { t } = useTranslation();
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 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">
<p>
{selectedTags.length === 1
? t("tag_deletion_confirmation_message")
: t("tags_deletion_confirmation_message", {
count: selectedTags.length,
})}
</p>
<Button className="ml-auto" variant="destructive" onClick={deleteTag}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,58 @@
import React, { useEffect, useState } from "react";
import { TagIncludingLinkCount } from "@linkwarden/types";
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 { useRemoveTag } from "@linkwarden/router/tags";
type Props = {
onClose: Function;
activeTag: TagIncludingLinkCount;
};
export default function DeleteTagModal({ onClose, activeTag }: Props) {
const { t } = useTranslation();
const [tag, setTag] = useState<TagIncludingLinkCount>(activeTag);
const deleteTag = useRemoveTag();
useEffect(() => {
setTag(activeTag);
}, []);
const submit = async () => {
const load = toast.loading(t("deleting"));
await deleteTag.mutateAsync(tag.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
onClose();
}
},
});
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("delete_tag")}</p>
<Separator className="my-3" />
<div className="flex flex-col gap-3">
<p>{t("tag_deletion_confirmation_message")}</p>
<Button className="ml-auto" variant="destructive" onClick={submit}>
<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

@@ -0,0 +1,77 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import TextInput from "@/components/TextInput";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { Button } from "@/components/ui/button";
import { useTranslation } from "next-i18next";
import { Separator } from "../ui/separator";
import { useUpsertTags } from "@linkwarden/router/tags";
type Props = {
onClose: Function;
};
export default function NewTagModal({ onClose }: Props) {
const { t } = useTranslation();
const upsertTags = useUpsertTags();
const initial = {
label: "",
};
const [tag, setTag] = useState(initial);
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating"));
await upsertTags.mutateAsync([tag], {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(t(error.message));
} else {
onClose();
toast.success(t("created"));
}
},
});
}
};
const inputRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
inputRef.current?.focus();
}, []);
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("create_new_tag")}</p>
<Separator className="my-3" />
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
ref={inputRef}
value={tag.label}
onChange={(e) => setTag({ ...tag, label: e.target.value })}
className="bg-base-200"
placeholder={t("tag_name_placeholder")}
/>
</div>
<div className="flex justify-end items-center mt-5">
<Button variant="accent" onClick={submit} disabled={!tag.label.trim()}>
{t("create_new_tag")}
</Button>
</div>
</Modal>
);
}

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";
@@ -52,13 +51,19 @@ export default function Sidebar({ className }: { className?: string }) {
className || ""
}`}
>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<SidebarHighlightLink
title={t("dashboard")}
href={`/dashboard`}
icon={"bi-house"}
active={active === `/dashboard`}
/>
<SidebarHighlightLink
title={t("links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
/>
<SidebarHighlightLink
title={t("pinned")}
href={`/links/pinned`}
@@ -66,17 +71,17 @@ export default function Sidebar({ className }: { className?: string }) {
active={active === `/links/pinned`}
/>
<SidebarHighlightLink
title={t("all_links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
/>
<SidebarHighlightLink
title={t("all_collections")}
title={t("collections")}
href={`/collections`}
icon={"bi-folder"}
active={active === `/collections`}
/>
<SidebarHighlightLink
title={t("tags")}
href={`/tags`}
icon={"bi-hash"}
active={active === `/tags`}
/>
</div>
<Disclosure defaultOpen={collectionDisclosure}>

View File

@@ -16,21 +16,11 @@ export default function SidebarHighlightLink({
<div
title={title}
className={`${
active || false
? "bg-primary/20"
: "bg-neutral-content/20 hover:bg-neutral/20"
} duration-200 px-3 py-2 cursor-pointer gap-2 w-full rounded-lg capitalize`}
active || false ? "bg-primary/20" : "hover:bg-neutral/20"
} duration-200 px-3 py-1 cursor-pointer flex items-center gap-2 w-full rounded-lg capitalize`}
>
<div
className={
"w-10 h-10 inline-flex items-center justify-center bg-black/10 dark:bg-white/5 rounded-full"
}
>
<i className={`${icon} text-primary text-xl drop-shadow`}></i>
</div>
<div className={"mt-1"}>
<p className="truncate w-full font-semibold text-xs">{title}</p>
</div>
<i className={`${icon} text-primary text-xl drop-shadow`}></i>
<p className="truncate w-full font-semibold text-sm">{title}</p>
</div>
</Link>
);

View File

@@ -0,0 +1,112 @@
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
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";
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"), {
year: "numeric",
month: "short",
day: "numeric",
});
const [deleteTagModal, setDeleteTagModal] = useState(false);
const router = useRouter();
return (
<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]"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
onSelect={() => setDeleteTagModal(true)}
className="text-error"
>
{t("delete_tag")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<h2 className="truncate leading-tight py-1 pr-8" title={tag.name}>
{tag.name}
</h2>
<div className="flex justify-between items-center mt-auto">
<div className="text-xs flex gap-1 items-center">
<i
className="bi-calendar3 text-neutral"
title={t("collection_publicly_shared")}
></i>
{formattedDate}
</div>
<div className="text-xs flex gap-1 items-center">
<i
className="bi-link-45deg text-lg leading-none text-neutral"
title={t("collection_publicly_shared")}
></i>
{tag._count?.links}
</div>
</div>
{deleteTagModal && (
<DeleteTagModal
onClose={() => setDeleteTagModal(false)}
activeTag={tag}
/>
)}
</div>
);
}

View File

@@ -1,59 +1,24 @@
import React, {
forwardRef,
ChangeEventHandler,
KeyboardEventHandler,
} from "react";
import { cn } from "@linkwarden/lib";
import React, { forwardRef } from "react";
type Props = {
autoFocus?: boolean;
value?: string;
type?: string;
placeholder?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
className?: string;
spellCheck?: boolean;
"data-testid"?: string;
};
export type TextInputProps = React.ComponentPropsWithoutRef<"input">;
const TextInput = forwardRef<HTMLInputElement, Props>(
(
{
autoFocus,
value,
type,
placeholder,
onChange,
onKeyDown,
className,
spellCheck,
"data-testid": dataTestId,
},
ref
) => {
const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
({ className, type = "text", ...rest }, ref) => {
return (
<input
ref={ref}
data-testid={dataTestId}
spellCheck={spellCheck}
autoFocus={autoFocus}
type={type ?? "text"}
placeholder={placeholder}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
className={`
w-full rounded-md p-2
border-neutral-content border-solid border
outline-none focus:border-primary duration-100
${className ?? ""}
`}
type={type}
className={cn(
"w-full rounded-md p-2 border-neutral-content border-solid border outline-none focus:border-primary duration-100",
className
)}
{...rest}
/>
);
}
);
// Give it a display name for easier debugging
TextInput.displayName = "TextInput";
export default TextInput;

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

@@ -0,0 +1,65 @@
import {
TagBulkDeletionSchema,
TagBulkDeletionSchemaType,
} from "@linkwarden/lib/schemaValidation";
import { prisma } from "@linkwarden/prisma";
export default async function bulkTagDelete(
userId: number,
body: TagBulkDeletionSchemaType
) {
const dataValidation = TagBulkDeletionSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const { tagIds } = dataValidation.data;
let deletedTag: number;
let affectedLinks: number[];
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,
id: {
in: tagIds,
},
},
})
).count;
await prisma.link.updateMany({
where: {
id: {
in: affectedLinks,
},
},
data: {
indexVersion: null,
},
});
return { response: deletedTag, status: 200 };
}

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

@@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import mergeTags from "@/lib/api/controllers/tags/mergeTags";
export default async function merge(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
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 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

@@ -12,7 +12,7 @@ import {
useUser,
} from "@linkwarden/router/user";
import { useConfig } from "@linkwarden/router/config";
import { useTags, useUpdateArchivalTags } from "@linkwarden/router/tags";
import { useTags, useUpsertTags } from "@linkwarden/router/tags";
import TagSelection from "@/components/InputSelect/TagSelection";
import { useArchivalTags } from "@/hooks/useArchivalTags";
import { isArchivalTag } from "@linkwarden/lib";
@@ -32,7 +32,7 @@ export default function Preference() {
const [submitLoader, setSubmitLoader] = useState(false);
const { data: account } = useUser() as any;
const { data: tags } = useTags();
const updateArchivalTags = useUpdateArchivalTags();
const upsertTags = useUpsertTags();
const {
ARCHIVAL_OPTIONS,
archivalTags,
@@ -172,8 +172,7 @@ export default function Preference() {
const promises = [];
if (hasAccountChanges) promises.push(updateUser.mutateAsync({ ...user }));
if (hasTagChanges)
promises.push(updateArchivalTags.mutateAsync(archivalTags));
if (hasTagChanges) promises.push(upsertTags.mutateAsync(archivalTags));
if (promises.length > 0) {
await Promise.all(promises);

View File

@@ -0,0 +1,268 @@
import MainLayout from "@/layouts/MainLayout";
import PageHeader from "@/components/PageHeader";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { useTranslation } from "next-i18next";
import { useTags } from "@linkwarden/router/tags";
import TagCard from "@/components/TagCard";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
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 [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 items-center">
<div className="flex items-center gap-3">
<PageHeader icon={"bi-hash"} title={t("tags")} />
<div className="relative">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="text-neutral" variant="ghost" size="icon">
<i className={"bi-three-dots text-neutral text-xl"}></i>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
<DropdownMenuItem onSelect={() => setNewTagModal(true)}>
<i className="bi-plus-lg" />
{t("new_tag")}
</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">
{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)} />}
{bulkDeleteModal && (
<BulkDeleteTagsModal
onClose={() => {
setBulkDeleteModal(false);
setEditMode(false);
}}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
)}
{mergeTagsModal && (
<MergeTagsModal
onClose={() => {
setMergeTagsModal(false);
setEditMode(false);
}}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
)}
</MainLayout>
);
}
export { getServerSideProps };

View File

@@ -493,5 +493,25 @@
"edit_layout": "Edit Layout",
"refresh_multiple_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve {{count}} links.",
"refresh_preserved_formats_confirmation_desc": "This will delete the current preserved formats and re-preserve this link.",
"tag_already_added": "This tag is already added."
"tag_already_added": "This tag is already added.",
"all_tags": "All Tags",
"new_tag": "New Tag",
"create_new_tag": "Create New Tag",
"tag_deletion_confirmation_message": "Are you sure you want to delete this Tag?",
"delete_tags_by_number_of_links": "Delete Tags with <0/> Links",
"delete_all_tags": "Delete all Tags",
"bulk_delete_tags": "Bulk Delete Tags",
"count_tags_deleted": "{{count}} Tags 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

@@ -5,7 +5,7 @@ import {
DashboardSectionType,
Theme,
} from "@linkwarden/prisma/client";
import { z } from "zod";
import { number, z } from "zod";
// const stringField = z.string({
// errorMap: (e) => ({
@@ -268,6 +268,19 @@ export const PostTagSchema = z.object({
export type PostTagSchemaType = z.infer<typeof PostTagSchema>;
export const TagBulkDeletionSchema = z.object({
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

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "aiGenerated" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -206,6 +206,7 @@ model Tag {
archiveAsReadable Boolean?
archiveAsWaybackMachine Boolean?
aiTag Boolean?
aiGenerated Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt

View File

@@ -8,6 +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 {
MergeTagsSchemaType,
TagBulkDeletionSchemaType,
} from "@linkwarden/lib/schemaValidation";
const useTags = (auth?: MobileAuth): UseQueryResult<Tag[], Error> => {
let status: "loading" | "authenticated" | "unauthenticated";
@@ -69,7 +73,7 @@ const useUpdateTag = () => {
});
};
const useUpdateArchivalTags = () => {
const useUpsertTags = () => {
const queryClient = useQueryClient();
return useMutation({
@@ -129,4 +133,61 @@ const useRemoveTag = () => {
});
};
export { useTags, useUpdateTag, useUpdateArchivalTags, useRemoveTag };
const useBulkTagDeletion = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: TagBulkDeletionSchemaType) => {
const response = await fetch(`/api/v1/tags`, {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
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"] });
},
});
};
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"