This commit is contained in:
daniel31x13
2025-08-26 17:14:20 -04:00
parent 803f344680
commit 395f5c01e1
16 changed files with 614 additions and 75 deletions

View File

@@ -0,0 +1,114 @@
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 { Separator } from "../ui/separator";
import { useBulkTagDeletion, useUpsertTags } from "@linkwarden/router/tags";
import { Trans, useTranslation } from "next-i18next";
type Props = {
onClose: Function;
};
export default function BulkDeleteTagsModal({ onClose }: Props) {
const { t } = useTranslation();
const [numberOfLinks, setNumberOfLinks] = useState(0);
const bulkDeleteTags = useBulkTagDeletion();
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("bulk_delete_tags")}</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>
<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>
</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,76 @@
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"
/>
</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

@@ -52,31 +52,37 @@ 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("pinned")}
href={`/links/pinned`}
icon={"bi-pin-angle"}
active={active === `/links/pinned`}
/>
<SidebarHighlightLink
title={t("all_links")}
href={`/links`}
icon={"bi-link-45deg"}
active={active === `/links`}
/>
<SidebarHighlightLink
title={t("pinned_links")}
href={`/links/pinned`}
icon={"bi-pin-angle"}
active={active === `/links/pinned`}
/>
<SidebarHighlightLink
title={t("all_collections")}
href={`/collections`}
icon={"bi-folder"}
active={active === `/collections`}
/>
<SidebarHighlightLink
title={t("all_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,82 @@
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 { TagIncludingLinkCount } from "@linkwarden/types";
import DeleteTagModal from "./ModalContent/DeleteTagModal";
export default function TagCard({ tag }: { tag: TagIncludingLinkCount }) {
const { t } = useTranslation();
const formattedDate = new Date(tag.createdAt).toLocaleString(t("locale"), {
year: "numeric",
month: "short",
day: "numeric",
});
const [deleteTagModal, setDeleteTagModal] = useState(false);
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>
<DropdownMenuContent
sideOffset={4}
side="bottom"
align="end"
className="z-[30]"
>
<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}>
{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,103 @@
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 { numberOfLinks, allTags } = dataValidation.data;
let deletedTag: number;
let affectedLinks: number[];
if (allTags) {
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,
},
},
},
});
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;
}
await prisma.link.updateMany({
where: {
id: {
in: affectedLinks,
},
},
data: {
indexVersion: null,
},
});
return { response: deletedTag, status: 200 };
}

View File

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

@@ -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,73 @@
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,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import NewTagModal from "@/components/ModalContent/NewTagModal";
import BulkDeleteTagsModal from "@/components/ModalContent/BulkDeleteTagsModal";
export default function Tags() {
const { t } = useTranslation();
const { data: tags = [] } = useTags();
const [newTagModal, setNewTagModal] = useState(false);
const [bulkDeleteTagsModal, setBulkDeleteTagsModal] = useState(false);
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full">
<div className="flex justify-between">
<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>
<DropdownMenuItem
onSelect={() => setBulkDeleteTagsModal(true)}
>
<i className="bi-trash" />
{t("bulk_delete_tags")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</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} />
))}
</div>
</div>
{newTagModal && <NewTagModal onClose={() => setNewTagModal(false)} />}
{bulkDeleteTagsModal && (
<BulkDeleteTagsModal onClose={() => setBulkDeleteTagsModal(false)} />
)}
</MainLayout>
);
}
export { getServerSideProps };

View File

@@ -493,5 +493,14 @@
"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"
}

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,13 @@ 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(),
});
export type TagBulkDeletionSchemaType = z.infer<typeof TagBulkDeletionSchema>;
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,7 @@ 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";
const useTags = (auth?: MobileAuth): UseQueryResult<Tag[], Error> => {
let status: "loading" | "authenticated" | "unauthenticated";
@@ -69,7 +70,7 @@ const useUpdateTag = () => {
});
};
const useUpdateArchivalTags = () => {
const useUpsertTags = () => {
const queryClient = useQueryClient();
return useMutation({
@@ -129,4 +130,35 @@ 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/bulk-delete`, {
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"] });
},
});
};
export {
useTags,
useUpdateTag,
useUpsertTags,
useRemoveTag,
useBulkTagDeletion,
};