mirror of
https://github.com/linkwarden/linkwarden.git
synced 2026-03-03 03:47:02 +00:00
Merge pull request #1406 from linkwarden/feat/tag-management
Feat/tag management
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
73
apps/web/components/ModalContent/BulkDeleteTagsModal.tsx
Normal file
73
apps/web/components/ModalContent/BulkDeleteTagsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
75
apps/web/components/ModalContent/MergeTagsModal.tsx
Normal file
75
apps/web/components/ModalContent/MergeTagsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
apps/web/components/ModalContent/NewTagModal.tsx
Normal file
77
apps/web/components/ModalContent/NewTagModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
112
apps/web/components/TagCard.tsx
Normal file
112
apps/web/components/TagCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
28
apps/web/components/ui/checkbox.tsx
Normal file
28
apps/web/components/ui/checkbox.tsx
Normal 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 }
|
||||
65
apps/web/lib/api/controllers/tags/bulkTagDelete.ts
Normal file
65
apps/web/lib/api/controllers/tags/bulkTagDelete.ts
Normal 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 };
|
||||
}
|
||||
76
apps/web/lib/api/controllers/tags/mergeTags.ts
Normal file
76
apps/web/lib/api/controllers/tags/mergeTags.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
19
apps/web/pages/api/v1/tags/merge.ts
Normal file
19
apps/web/pages/api/v1/tags/merge.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
268
apps/web/pages/tags/index.tsx
Normal file
268
apps/web/pages/tags/index.tsx
Normal 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 };
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
32
yarn.lock
32
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user