mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 03:47:02 +00:00
init
This commit is contained in:
114
apps/web/components/ModalContent/BulkDeleteTagsModal.tsx
Normal file
114
apps/web/components/ModalContent/BulkDeleteTagsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/web/components/ModalContent/DeleteTagModal.tsx
Normal file
58
apps/web/components/ModalContent/DeleteTagModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web/components/ModalContent/NewTagModal.tsx
Normal file
76
apps/web/components/ModalContent/NewTagModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
82
apps/web/components/TagCard.tsx
Normal file
82
apps/web/components/TagCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
103
apps/web/lib/api/controllers/tags/bulkTagDelete.ts
Normal file
103
apps/web/lib/api/controllers/tags/bulkTagDelete.ts
Normal 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 };
|
||||
}
|
||||
22
apps/web/pages/api/v1/tags/bulk-delete.ts
Normal file
22
apps/web/pages/api/v1/tags/bulk-delete.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
73
apps/web/pages/tags/index.tsx
Normal file
73
apps/web/pages/tags/index.tsx
Normal 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 };
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Tag" ADD COLUMN "aiGenerated" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user