Merge pull request #466 from IsaacWise06/issue-433
feat(links): Allow users to bulk edit/delete links
This commit is contained in:
commit
79bd95f650
|
@ -4,18 +4,26 @@ import { useEffect, useState } from "react";
|
|||
import { styles } from "./styles";
|
||||
import { Options } from "./types";
|
||||
import CreatableSelect from "react-select/creatable";
|
||||
import Select from "react-select";
|
||||
|
||||
type Props = {
|
||||
onChange: any;
|
||||
defaultValue:
|
||||
showDefaultValue?: boolean;
|
||||
defaultValue?:
|
||||
| {
|
||||
label: string;
|
||||
value?: number;
|
||||
}
|
||||
| undefined;
|
||||
creatable?: boolean;
|
||||
};
|
||||
|
||||
export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
||||
export default function CollectionSelection({
|
||||
onChange,
|
||||
defaultValue,
|
||||
showDefaultValue = true,
|
||||
creatable = true,
|
||||
}: Props) {
|
||||
const { collections } = useCollectionStore();
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -42,16 +50,31 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
|||
setOptions(formatedCollections);
|
||||
}, [collections]);
|
||||
|
||||
return (
|
||||
<CreatableSelect
|
||||
isClearable={false}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
styles={styles}
|
||||
defaultValue={defaultValue}
|
||||
// menuPosition="fixed"
|
||||
/>
|
||||
);
|
||||
if (creatable) {
|
||||
return (
|
||||
<CreatableSelect
|
||||
isClearable={false}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
styles={styles}
|
||||
defaultValue={showDefaultValue ? defaultValue : null}
|
||||
// menuPosition="fixed"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Select
|
||||
isClearable={false}
|
||||
className="react-select-container"
|
||||
classNamePrefix="react-select"
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
styles={styles}
|
||||
defaultValue={showDefaultValue ? defaultValue : null}
|
||||
// menuPosition="fixed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,8 +3,12 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|||
|
||||
export default function CardView({
|
||||
links,
|
||||
showCheckbox = true,
|
||||
editMode,
|
||||
}: {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
showCheckbox?: boolean;
|
||||
editMode?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||
|
@ -15,6 +19,7 @@ export default function CardView({
|
|||
link={e}
|
||||
count={i}
|
||||
flipDropdown={i === links.length - 1}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -3,11 +3,13 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|||
|
||||
export default function ListView({
|
||||
links,
|
||||
editMode,
|
||||
}: {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
editMode?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-1 flex-col">
|
||||
{links.map((e, i) => {
|
||||
return (
|
||||
<LinkList
|
||||
|
@ -15,6 +17,7 @@ export default function ListView({
|
|||
link={e}
|
||||
count={i}
|
||||
flipDropdown={i === links.length - 1}
|
||||
editMode={editMode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
@ -17,22 +17,32 @@ import LinkIcon from "./LinkComponents/LinkIcon";
|
|||
import useOnScreen from "@/hooks/useOnScreen";
|
||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||
import useAccountStore from "@/store/account";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
count: number;
|
||||
className?: string;
|
||||
flipDropdown?: boolean;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkGrid({
|
||||
link,
|
||||
flipDropdown,
|
||||
}: Props) {
|
||||
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
||||
const { collections } = useCollectionStore();
|
||||
const { account } = useAccountStore();
|
||||
|
||||
const { links, getLink } = useLinkStore();
|
||||
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
if (selectedLinks.includes(link)) {
|
||||
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
|
@ -59,6 +69,7 @@ export default function LinkGrid({
|
|||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isVisible = useOnScreen(ref);
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
@ -82,131 +93,261 @@ export default function LinkGrid({
|
|||
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border-primary bg-base-300"
|
||||
: "border-neutral-content";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative"
|
||||
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
: editMode
|
||||
? toast.error(
|
||||
"You don't have permission to edit or delete this item."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={generateLinkHref(link, account)}
|
||||
target="_blank"
|
||||
className="rounded-2xl cursor-pointer"
|
||||
>
|
||||
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
|
||||
style={{ filter: "blur(2px)" }}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
|
||||
) : (
|
||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||
)}
|
||||
<div
|
||||
style={
|
||||
{
|
||||
// background:
|
||||
// "radial-gradient(circle, rgba(255, 255, 255, 0.5), transparent)",
|
||||
}
|
||||
}
|
||||
className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"
|
||||
{!editMode ? (
|
||||
<>
|
||||
<Link
|
||||
href={generateLinkHref(link, account)}
|
||||
target="_blank"
|
||||
className="rounded-2xl cursor-pointer"
|
||||
>
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="p-3 mt-1">
|
||||
<p className="truncate w-full pr-8 text-primary">
|
||||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<div title={link.url || ""} className="w-fit">
|
||||
<div className="flex gap-1 item-center select-none text-neutral mt-1">
|
||||
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
|
||||
<p className="text-sm truncate">{shortendURL}</p>
|
||||
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
|
||||
style={{ filter: "blur(2px)" }}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
|
||||
) : (
|
||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||
)}
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||
<div className="cursor-pointer w-fit">
|
||||
{collection ? (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
) : undefined}
|
||||
</div>
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="p-3 mt-1">
|
||||
<p className="truncate w-full pr-8 text-primary">
|
||||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
{showInfo ? (
|
||||
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
||||
<div
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
|
||||
>
|
||||
<i className="bi-x text-neutral text-2xl"></i>
|
||||
</div>
|
||||
<p className="text-neutral text-lg font-semibold">Description</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
<p>
|
||||
{link.description ? (
|
||||
unescapeString(link.description)
|
||||
) : (
|
||||
<span className="text-neutral text-sm">
|
||||
No description provided.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags[0] ? (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
<div title={link.url || ""} className="w-fit">
|
||||
<div className="flex gap-1 item-center select-none text-neutral mt-1">
|
||||
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
|
||||
<p className="text-sm truncate">{shortendURL}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-[10.75rem] right-3"
|
||||
toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
linkInfo={showInfo}
|
||||
flipDropdown={flipDropdown}
|
||||
/>
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||
<div className="cursor-pointer w-fit">
|
||||
{collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{showInfo && (
|
||||
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
||||
<div
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
|
||||
>
|
||||
<i className="bi-x text-neutral text-2xl"></i>
|
||||
</div>
|
||||
<p className="text-neutral text-lg font-semibold">Description</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
<p>
|
||||
{link.description ? (
|
||||
unescapeString(link.description)
|
||||
) : (
|
||||
<span className="text-neutral text-sm">
|
||||
No description provided.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags[0] && (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||
Tags
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-[10.75rem] right-3"
|
||||
toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
linkInfo={showInfo}
|
||||
flipDropdown={flipDropdown}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-2xl cursor-pointer">
|
||||
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
||||
{previewAvailable(link) ? (
|
||||
<Image
|
||||
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
|
||||
width={1280}
|
||||
height={720}
|
||||
alt=""
|
||||
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
|
||||
style={{ filter: "blur(2px)" }}
|
||||
draggable="false"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : link.preview === "unavailable" ? (
|
||||
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
|
||||
) : (
|
||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||
)}
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
|
||||
<LinkIcon link={link} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="p-3 mt-1">
|
||||
<p className="truncate w-full pr-8 text-primary">
|
||||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<div title={link.url || ""} className="w-fit">
|
||||
<div className="flex gap-1 item-center select-none text-neutral mt-1">
|
||||
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
|
||||
<p className="text-sm truncate">{shortendURL}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||
<div className="cursor-pointer w-fit">
|
||||
{collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInfo && (
|
||||
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
||||
<div
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
|
||||
>
|
||||
<i className="bi-x text-neutral text-2xl"></i>
|
||||
</div>
|
||||
<p className="text-neutral text-lg font-semibold">Description</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
<p>
|
||||
{link.description ? (
|
||||
unescapeString(link.description)
|
||||
) : (
|
||||
<span className="text-neutral text-sm">
|
||||
No description provided.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags[0] && (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||
Tags
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-[10.75rem] right-3"
|
||||
toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
linkInfo={showInfo}
|
||||
flipDropdown={flipDropdown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -101,7 +101,7 @@ export default function LinkGrid({ link }: Props) {
|
|||
</div>
|
||||
|
||||
<LinkActions
|
||||
toggleShowInfo={() => { }}
|
||||
toggleShowInfo={() => {}}
|
||||
linkInfo={false}
|
||||
link={link}
|
||||
collection={collection}
|
||||
|
|
|
@ -14,21 +14,41 @@ import Link from "next/link";
|
|||
import { isPWA } from "@/lib/client/utils";
|
||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||
import useAccountStore from "@/store/account";
|
||||
import usePermissions from "@/hooks/usePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type Props = {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
count: number;
|
||||
className?: string;
|
||||
flipDropdown?: boolean;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function LinkCardCompact({
|
||||
link,
|
||||
flipDropdown,
|
||||
editMode,
|
||||
}: Props) {
|
||||
const { collections } = useCollectionStore();
|
||||
const { account } = useAccountStore();
|
||||
const { links } = useLinkStore();
|
||||
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||
|
||||
const handleCheckboxClick = (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => {
|
||||
const linkIndex = selectedLinks.findIndex(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
);
|
||||
|
||||
if (linkIndex !== -1) {
|
||||
const updatedLinks = [...selectedLinks];
|
||||
updatedLinks.splice(linkIndex, 1);
|
||||
setSelectedLinks(updatedLinks);
|
||||
} else {
|
||||
setSelectedLinks([...selectedLinks, link]);
|
||||
}
|
||||
};
|
||||
|
||||
let shortendURL;
|
||||
|
||||
|
@ -53,103 +73,186 @@ export default function LinkCardCompact({
|
|||
);
|
||||
}, [collections, links]);
|
||||
|
||||
const permissions = usePermissions(collection?.id as number);
|
||||
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
const selectedStyle = selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)
|
||||
? "border border-primary bg-base-300"
|
||||
: "border-transparent";
|
||||
|
||||
const selectable =
|
||||
editMode &&
|
||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`border-neutral-content relative ${!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
|
||||
} duration-200 rounded-lg`}
|
||||
className={`${selectedStyle} border relative items-center flex ${
|
||||
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
|
||||
} duration-200 rounded-lg`}
|
||||
onClick={() =>
|
||||
selectable
|
||||
? handleCheckboxClick(link)
|
||||
: editMode
|
||||
? toast.error(
|
||||
"You don't have permission to edit or delete this item."
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Link
|
||||
href={generateLinkHref(link, account)}
|
||||
target="_blank"
|
||||
className="flex items-start cursor-pointer"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
||||
</div>
|
||||
|
||||
<div className="w-[calc(100%-56px)] ml-2">
|
||||
<p className="line-clamp-1 mr-8 text-primary">
|
||||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
||||
<div className="flex items-center gap-x-3 w-fit text-neutral flex-wrap">
|
||||
{collection ? (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
) : undefined}
|
||||
{link.url ? (
|
||||
<div className="flex items-center gap-1 w-fit text-neutral truncate">
|
||||
<i className="bi-link-45deg text-lg" />
|
||||
<p className="truncate w-full">{shortendURL}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="badge badge-primary badge-sm my-1">
|
||||
{link.type}
|
||||
</div>
|
||||
)}
|
||||
<LinkDate link={link} />
|
||||
{/* {showCheckbox &&
|
||||
editMode &&
|
||||
(permissions === true ||
|
||||
permissions?.canCreate ||
|
||||
permissions?.canDelete) && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary my-auto mr-2"
|
||||
checked={selectedLinks.some(
|
||||
(selectedLink) => selectedLink.id === link.id
|
||||
)}
|
||||
onChange={() => handleCheckboxClick(link)}
|
||||
/>
|
||||
)} */}
|
||||
{!editMode ? (
|
||||
<>
|
||||
<Link
|
||||
href={generateLinkHref(link, account)}
|
||||
target="_blank"
|
||||
className="flex items-center cursor-pointer"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-3 right-3"
|
||||
flipDropdown={flipDropdown}
|
||||
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
// linkInfo={showInfo}
|
||||
/>
|
||||
{showInfo ? (
|
||||
<div>
|
||||
<div className="pb-3 mt-1 px-3">
|
||||
<p className="text-neutral text-lg font-semibold">Description</p>
|
||||
<div className="w-[calc(100%-56px)] ml-2">
|
||||
<p className="line-clamp-1 mr-8 text-primary">
|
||||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
<p>
|
||||
{link.description ? (
|
||||
unescapeString(link.description)
|
||||
) : (
|
||||
<span className="text-neutral text-sm">
|
||||
No description provided.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags[0] ? (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||
Tags
|
||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
||||
<div className="flex items-center gap-x-3 w-fit text-neutral flex-wrap">
|
||||
{collection && (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
)}
|
||||
{link.url ? (
|
||||
<div className="flex items-center gap-1 w-fit text-neutral truncate">
|
||||
<i className="bi-link-45deg text-lg" />
|
||||
<p className="truncate w-full">{shortendURL}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="badge badge-primary badge-sm my-1">
|
||||
{link.type}
|
||||
</div>
|
||||
)}
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-3 right-3"
|
||||
flipDropdown={flipDropdown}
|
||||
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
// linkInfo={showInfo}
|
||||
/>
|
||||
{showInfo && (
|
||||
<div>
|
||||
<div className="pb-3 mt-1 px-3">
|
||||
<p className="text-neutral text-lg font-semibold">
|
||||
Description
|
||||
</p>
|
||||
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
<p>
|
||||
{link.description ? (
|
||||
unescapeString(link.description)
|
||||
) : (
|
||||
<span className="text-neutral text-sm">
|
||||
No description provided.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{link.tags[0] && (
|
||||
<>
|
||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||
Tags
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||
|
||||
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{link.tags.map((e, i) => (
|
||||
<Link
|
||||
href={"/tags/" + e.id}
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||
>
|
||||
#{e.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center cursor-pointer">
|
||||
<div className="shrink-0">
|
||||
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
||||
</div>
|
||||
|
||||
<div className="w-[calc(100%-56px)] ml-2">
|
||||
<p className="line-clamp-1 mr-8 text-primary select-none">
|
||||
{unescapeString(link.name || link.description) || link.url}
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
||||
<div className="flex items-center gap-x-3 w-fit text-neutral flex-wrap">
|
||||
{collection ? (
|
||||
<LinkCollection link={link} collection={collection} />
|
||||
) : undefined}
|
||||
{link.url ? (
|
||||
<div className="flex items-center gap-1 w-fit text-neutral truncate">
|
||||
<i className="bi-link-45deg text-lg" />
|
||||
<p className="truncate w-full select-none">
|
||||
{shortendURL}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="badge badge-primary badge-sm my-1 select-none">
|
||||
{link.type}
|
||||
</div>
|
||||
)}
|
||||
<LinkDate link={link} />
|
||||
</div>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
<LinkActions
|
||||
link={link}
|
||||
collection={collection}
|
||||
position="top-3 right-3"
|
||||
flipDropdown={flipDropdown}
|
||||
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||
// linkInfo={showInfo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divider my-0 last:hidden h-[1px]"></div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import React from "react";
|
||||
import useLinkStore from "@/store/links";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
};
|
||||
|
||||
export default function BulkDeleteLinksModal({ onClose }: Props) {
|
||||
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore();
|
||||
|
||||
const deleteLink = async () => {
|
||||
const load = toast.loading(
|
||||
`Deleting ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}...`
|
||||
);
|
||||
|
||||
const response = await deleteLinksById(
|
||||
selectedLinks.map((link) => link.id as number)
|
||||
);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(
|
||||
`Deleted ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}`
|
||||
);
|
||||
|
||||
setSelectedLinks([]);
|
||||
onClose();
|
||||
} else toast.error(response.data as string);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin text-red-500">
|
||||
Delete {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
|
||||
<div className="divider mb-3 mt-1"></div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{selectedLinks.length > 1 ? (
|
||||
<p>Are you sure you want to delete {selectedLinks.length} links?</p>
|
||||
) : (
|
||||
<p>Are you sure you want to delete this link?</p>
|
||||
)}
|
||||
|
||||
<div role="alert" className="alert alert-warning">
|
||||
<i className="bi-exclamation-triangle text-xl" />
|
||||
<span>
|
||||
<b>Warning:</b> This action is irreversible!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
|
||||
'Delete' to bypass this confirmation in the future.
|
||||
</p>
|
||||
|
||||
<button
|
||||
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
|
||||
onClick={deleteLink}
|
||||
>
|
||||
<i className="bi-trash text-xl" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useState } from "react";
|
||||
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
||||
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||
import useLinkStore from "@/store/links";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "../Modal";
|
||||
|
||||
type Props = {
|
||||
onClose: Function;
|
||||
};
|
||||
|
||||
export default function BulkEditLinksModal({ onClose }: Props) {
|
||||
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
||||
const [updatedValues, setUpdatedValues] = useState<
|
||||
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
|
||||
>({ tags: [] });
|
||||
|
||||
const setCollection = (e: any) => {
|
||||
const collectionId = e?.value || null;
|
||||
console.log(updatedValues);
|
||||
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
|
||||
};
|
||||
|
||||
const setTags = (e: any) => {
|
||||
const tags = e.map((tag: any) => ({ name: tag.label }));
|
||||
setUpdatedValues((prevValues) => ({ ...prevValues, tags }));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!submitLoader) {
|
||||
setSubmitLoader(true);
|
||||
|
||||
const load = toast.loading("Updating...");
|
||||
|
||||
const response = await updateLinks(
|
||||
selectedLinks,
|
||||
removePreviousTags,
|
||||
updatedValues
|
||||
);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(`Updated!`);
|
||||
setSelectedLinks([]);
|
||||
onClose();
|
||||
} else toast.error(response.data as string);
|
||||
|
||||
setSubmitLoader(false);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal toggleModal={onClose}>
|
||||
<p className="text-xl font-thin">
|
||||
Edit {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
<div className="divider mb-3 mt-1"></div>
|
||||
<div className="mt-5">
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="mb-2">Move to Collection</p>
|
||||
<CollectionSelection
|
||||
showDefaultValue={false}
|
||||
onChange={setCollection}
|
||||
creatable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2">Add Tags</p>
|
||||
<TagSelection onChange={setTags} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:ml-auto w-1/2 p-3">
|
||||
<label className="flex items-center gap-2 ">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={removePreviousTags}
|
||||
onChange={(e) => setRemovePreviousTags(e.target.checked)}
|
||||
/>
|
||||
Remove previous tags
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end items-center mt-5">
|
||||
<button
|
||||
className="btn btn-accent dark:border-violet-400 text-white"
|
||||
onClick={submit}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -15,7 +15,6 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
|||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||
|
||||
const { removeLink } = useLinkStore();
|
||||
const [submitLoader, setSubmitLoader] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
|
|
@ -234,11 +234,8 @@ export default function EditCollectionSharingModal({
|
|||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
key={i}
|
||||
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
|
||||
>
|
||||
<React.Fragment key={i}>
|
||||
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none">
|
||||
<div
|
||||
className={"flex items-center justify-between w-full"}
|
||||
>
|
||||
|
@ -433,7 +430,7 @@ export default function EditCollectionSharingModal({
|
|||
</div>
|
||||
</div>
|
||||
<div className="divider my-0 last:hidden h-[3px]"></div>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
@ -124,6 +124,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
|||
label: "Unorganized",
|
||||
}
|
||||
}
|
||||
creatable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
@ -14,7 +14,7 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
|
|||
tabIndex={0}
|
||||
role="button"
|
||||
onMouseDown={dropdownTriggerer}
|
||||
className="btn btn-sm btn-square btn-ghost"
|
||||
className="btn btn-sm btn-square btn-ghost border-none"
|
||||
>
|
||||
<i className="bi-chevron-expand text-neutral text-2xl"></i>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
|
||||
import { ViewMode } from "@/types/global";
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import useAccountStore from "@/store/account";
|
||||
import useCollectionStore from "@/store/collections";
|
||||
import { Member } from "@/types/global";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useCollectivePermissions(collectionIds: number[]) {
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
const { account } = useAccountStore();
|
||||
|
||||
const [permissions, setPermissions] = useState<Member | true>();
|
||||
useEffect(() => {
|
||||
for (const collectionId of collectionIds) {
|
||||
const collection = collections.find((e) => e.id === collectionId);
|
||||
|
||||
if (collection) {
|
||||
let getPermission: Member | undefined = collection.members.find(
|
||||
(e) => e.userId === account.id
|
||||
);
|
||||
|
||||
if (
|
||||
getPermission?.canCreate === false &&
|
||||
getPermission?.canUpdate === false &&
|
||||
getPermission?.canDelete === false
|
||||
)
|
||||
getPermission = undefined;
|
||||
|
||||
setPermissions(account.id === collection.ownerId || getPermission);
|
||||
}
|
||||
}
|
||||
}, [account, collections, collectionIds]);
|
||||
|
||||
return permissions;
|
||||
}
|
|
@ -18,7 +18,8 @@ export default function useLinks(
|
|||
searchByTextContent,
|
||||
}: LinkRequestQuery = { sort: 0 }
|
||||
) {
|
||||
const { links, setLinks, resetLinks } = useLinkStore();
|
||||
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } =
|
||||
useLinkStore();
|
||||
const router = useRouter();
|
||||
|
||||
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
|
||||
|
@ -68,8 +69,12 @@ export default function useLinks(
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Save the selected links before resetting the links
|
||||
// and then restore the selected links after resetting the links
|
||||
const previouslySelected = selectedLinks;
|
||||
resetLinks();
|
||||
|
||||
setSelectedLinks(previouslySelected);
|
||||
getLinks(true);
|
||||
}, [
|
||||
router,
|
||||
|
|
|
@ -23,8 +23,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
...devices["Desktop Chrome"],
|
||||
ignoreHTTPSErrors:
|
||||
process.env.IGNORE_HTTPS_ERRORS === "true",
|
||||
ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
|
||||
export default async function deleteLinksById(
|
||||
userId: number,
|
||||
linkIds: number[]
|
||||
) {
|
||||
if (!linkIds || linkIds.length === 0) {
|
||||
return { response: "Please choose valid links.", status: 401 };
|
||||
}
|
||||
|
||||
const collectionIsAccessibleArray = [];
|
||||
|
||||
// Check if the user has access to the collection of each link
|
||||
// if any of the links are not accessible, return an error
|
||||
// if all links are accessible, continue with the deletion
|
||||
// and add the collection to the collectionIsAccessibleArray
|
||||
for (const linkId of linkIds) {
|
||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId && e.canDelete
|
||||
);
|
||||
|
||||
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) {
|
||||
return { response: "Collection is not accessible.", status: 401 };
|
||||
}
|
||||
|
||||
collectionIsAccessibleArray.push(collectionIsAccessible);
|
||||
}
|
||||
|
||||
const deletedLinks = await prisma.link.deleteMany({
|
||||
where: {
|
||||
id: { in: linkIds },
|
||||
},
|
||||
});
|
||||
|
||||
// Loop through each link and delete the associated files
|
||||
// if the user has access to the collection
|
||||
for (let i = 0; i < linkIds.length; i++) {
|
||||
const linkId = linkIds[i];
|
||||
const collectionIsAccessible = collectionIsAccessibleArray[i];
|
||||
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||
});
|
||||
removeFile({
|
||||
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||
});
|
||||
}
|
||||
|
||||
return { response: deletedLinks, status: 200 };
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import updateLinkById from "../linkId/updateLinkById";
|
||||
|
||||
export default async function updateLinks(
|
||||
userId: number,
|
||||
links: LinkIncludingShortenedCollectionAndTags[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
"tags" | "collectionId"
|
||||
>
|
||||
) {
|
||||
let allUpdatesSuccessful = true;
|
||||
|
||||
// Have to use a loop here rather than updateMany, see the following:
|
||||
// https://github.com/prisma/prisma/issues/3143
|
||||
for (const link of links) {
|
||||
let updatedTags = [...link.tags, ...(newData.tags ?? [])];
|
||||
|
||||
if (removePreviousTags) {
|
||||
// If removePreviousTags is true, replace the existing tags with new tags
|
||||
updatedTags = [...(newData.tags ?? [])];
|
||||
}
|
||||
|
||||
const updatedData: LinkIncludingShortenedCollectionAndTags = {
|
||||
...link,
|
||||
tags: updatedTags,
|
||||
collection: {
|
||||
...link.collection,
|
||||
id: newData.collectionId ?? link.collection.id,
|
||||
},
|
||||
};
|
||||
|
||||
const updatedLink = await updateLinkById(
|
||||
userId,
|
||||
link.id as number,
|
||||
updatedData
|
||||
);
|
||||
|
||||
if (updatedLink.status !== 200) {
|
||||
allUpdatesSuccessful = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allUpdatesSuccessful) {
|
||||
return { response: "All links updated successfully", status: 200 };
|
||||
} else {
|
||||
return { response: "Some links failed to update", status: 400 };
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import { Collection, Link, UsersAndCollections } from "@prisma/client";
|
||||
import { Link, UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import removeFile from "@/lib/api/storage/removeFile";
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { prisma } from "@/lib/api/db";
|
||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import { Collection, Link, UsersAndCollections } from "@prisma/client";
|
||||
import { UsersAndCollections } from "@prisma/client";
|
||||
import getPermission from "@/lib/api/getPermission";
|
||||
import moveFile from "@/lib/api/storage/moveFile";
|
||||
|
||||
|
@ -16,6 +16,10 @@ export default async function updateLinkById(
|
|||
};
|
||||
|
||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||
const targetCollectionIsAccessible = await getPermission({
|
||||
userId,
|
||||
collectionId: data.collection.id,
|
||||
});
|
||||
|
||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
|
||||
|
@ -25,6 +29,28 @@ export default async function updateLinkById(
|
|||
collectionIsAccessible?.ownerId === data.collection.ownerId &&
|
||||
data.collection.ownerId === userId;
|
||||
|
||||
const targetCollectionsAccessible =
|
||||
targetCollectionIsAccessible?.ownerId === userId;
|
||||
|
||||
const targetCollectionMatchesData = data.collection.id
|
||||
? data.collection.id === targetCollectionIsAccessible?.id
|
||||
: true && data.collection.name
|
||||
? data.collection.name === targetCollectionIsAccessible?.name
|
||||
: true && data.collection.ownerId
|
||||
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
|
||||
: true;
|
||||
|
||||
if (!targetCollectionsAccessible)
|
||||
return {
|
||||
response: "Target collection is not accessible.",
|
||||
status: 401,
|
||||
};
|
||||
else if (!targetCollectionMatchesData)
|
||||
return {
|
||||
response: "Target collection does not match the data.",
|
||||
status: 401,
|
||||
};
|
||||
|
||||
const unauthorizedSwitchCollection =
|
||||
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
|
||||
|
||||
|
|
|
@ -97,18 +97,18 @@ export default async function updateUserById(
|
|||
id: { not: userId },
|
||||
OR: emailEnabled
|
||||
? [
|
||||
{
|
||||
username: data.username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: data.email?.toLowerCase(),
|
||||
},
|
||||
]
|
||||
{
|
||||
username: data.username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: data.email?.toLowerCase(),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
username: data.username.toLowerCase(),
|
||||
},
|
||||
],
|
||||
{
|
||||
username: data.username.toLowerCase(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -3,12 +3,14 @@ import { prisma } from "@/lib/api/db";
|
|||
type Props = {
|
||||
userId: number;
|
||||
collectionId?: number;
|
||||
collectionName?: string;
|
||||
linkId?: number;
|
||||
};
|
||||
|
||||
export default async function getPermission({
|
||||
userId,
|
||||
collectionId,
|
||||
collectionName,
|
||||
linkId,
|
||||
}: Props) {
|
||||
if (linkId) {
|
||||
|
@ -24,10 +26,11 @@ export default async function getPermission({
|
|||
});
|
||||
|
||||
return check;
|
||||
} else if (collectionId) {
|
||||
} else if (collectionId || collectionName) {
|
||||
const check = await prisma.collection.findFirst({
|
||||
where: {
|
||||
id: collectionId,
|
||||
id: collectionId || undefined,
|
||||
name: collectionName || undefined,
|
||||
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
||||
},
|
||||
include: { members: true },
|
||||
|
|
|
@ -1,27 +1,39 @@
|
|||
import { AccountSettings, ArchivedFormat, LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
import {
|
||||
AccountSettings,
|
||||
ArchivedFormat,
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
} from "@/types/global";
|
||||
import { LinksRouteTo } from "@prisma/client";
|
||||
import { pdfAvailable, readabilityAvailable, screenshotAvailable } from "../shared/getArchiveValidity";
|
||||
import {
|
||||
pdfAvailable,
|
||||
readabilityAvailable,
|
||||
screenshotAvailable,
|
||||
} from "../shared/getArchiveValidity";
|
||||
|
||||
export const generateLinkHref = (link: LinkIncludingShortenedCollectionAndTags, account: AccountSettings): string => {
|
||||
export const generateLinkHref = (
|
||||
link: LinkIncludingShortenedCollectionAndTags,
|
||||
account: AccountSettings
|
||||
): string => {
|
||||
// Return the links href based on the account's preference
|
||||
// If the user's preference is not available, return the original link
|
||||
switch (account.linksRouteTo) {
|
||||
case LinksRouteTo.ORIGINAL:
|
||||
return link.url || "";
|
||||
case LinksRouteTo.PDF:
|
||||
if (!pdfAvailable(link)) return link.url || "";
|
||||
|
||||
// Return the links href based on the account's preference
|
||||
// If the user's preference is not available, return the original link
|
||||
switch (account.linksRouteTo) {
|
||||
case LinksRouteTo.ORIGINAL:
|
||||
return link.url || '';
|
||||
case LinksRouteTo.PDF:
|
||||
if (!pdfAvailable(link)) return link.url || '';
|
||||
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
|
||||
case LinksRouteTo.READABLE:
|
||||
if (!readabilityAvailable(link)) return link.url || "";
|
||||
|
||||
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
|
||||
case LinksRouteTo.READABLE:
|
||||
if (!readabilityAvailable(link)) return link.url || '';
|
||||
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
|
||||
case LinksRouteTo.SCREENSHOT:
|
||||
if (!screenshotAvailable(link)) return link.url || "";
|
||||
|
||||
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
|
||||
case LinksRouteTo.SCREENSHOT:
|
||||
if (!screenshotAvailable(link)) return link.url || '';
|
||||
|
||||
return `/preserved/${link?.id}?format=${link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg}`;
|
||||
default:
|
||||
return link.url || '';
|
||||
}
|
||||
};
|
||||
return `/preserved/${link?.id}?format=${
|
||||
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
|
||||
}`;
|
||||
default:
|
||||
return link.url || "";
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||
|
||||
export function screenshotAvailable(link: LinkIncludingShortenedCollectionAndTags) {
|
||||
export function screenshotAvailable(
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) {
|
||||
return (
|
||||
link &&
|
||||
link.image &&
|
||||
|
@ -15,7 +17,9 @@ export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) {
|
|||
);
|
||||
}
|
||||
|
||||
export function readabilityAvailable(link: LinkIncludingShortenedCollectionAndTags) {
|
||||
export function readabilityAvailable(
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) {
|
||||
return (
|
||||
link &&
|
||||
link.readable &&
|
||||
|
|
|
@ -3,6 +3,8 @@ import getLinks from "@/lib/api/controllers/links/getLinks";
|
|||
import postLink from "@/lib/api/controllers/links/postLink";
|
||||
import { LinkRequestQuery } from "@/types/global";
|
||||
import verifyUser from "@/lib/api/verifyUser";
|
||||
import deleteLinksById from "@/lib/api/controllers/links/bulk/deleteLinksById";
|
||||
import updateLinks from "@/lib/api/controllers/links/bulk/updateLinks";
|
||||
|
||||
export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||
const user = await verifyUser({ req, res });
|
||||
|
@ -39,5 +41,20 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
|||
return res.status(newlink.status).json({
|
||||
response: newlink.response,
|
||||
});
|
||||
} else if (req.method === "PUT") {
|
||||
const updated = await updateLinks(
|
||||
user.id,
|
||||
req.body.links,
|
||||
req.body.removePreviousTags,
|
||||
req.body.newData
|
||||
);
|
||||
return res.status(updated.status).json({
|
||||
response: updated.response,
|
||||
});
|
||||
} else if (req.method === "DELETE") {
|
||||
const deleted = await deleteLinksById(user.id, req.body.linkIds);
|
||||
return res.status(deleted.status).json({
|
||||
response: deleted.response,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,15 +24,18 @@ import CardView from "@/components/LinkViews/Layouts/CardView";
|
|||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import Link from "next/link";
|
||||
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
|
||||
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
|
||||
import toast from "react-hot-toast";
|
||||
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
|
||||
|
||||
export default function Index() {
|
||||
const { settings } = useLocalSettingsStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
|
||||
useLinkStore();
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
|
@ -81,6 +84,9 @@ export default function Index() {
|
|||
};
|
||||
|
||||
fetchOwner();
|
||||
|
||||
// When the collection changes, reset the selected links
|
||||
setSelectedLinks([]);
|
||||
}, [activeCollection]);
|
||||
|
||||
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
||||
|
@ -88,6 +94,14 @@ export default function Index() {
|
|||
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
||||
useState(false);
|
||||
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
|
||||
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
|
||||
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setEditMode(false);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
|
@ -102,6 +116,35 @@ export default function Index() {
|
|||
// @ts-ignore
|
||||
const LinkComponent = linkView[viewMode];
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLinks.length === links.length) {
|
||||
setSelectedLinks([]);
|
||||
} else {
|
||||
setSelectedLinks(links.map((link) => link));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(
|
||||
`Deleting ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}...`
|
||||
);
|
||||
|
||||
const response = await deleteLinksById(
|
||||
selectedLinks.map((link) => link.id as number)
|
||||
);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
response.ok &&
|
||||
toast.success(
|
||||
`Deleted ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}!`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div
|
||||
|
@ -135,7 +178,7 @@ export default function Index() {
|
|||
<i className="bi-three-dots text-xl" title="More"></i>
|
||||
</div>
|
||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
|
||||
{permissions === true ? (
|
||||
{permissions === true && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -148,7 +191,7 @@ export default function Index() {
|
|||
Edit Collection Info
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
)}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -163,7 +206,7 @@ export default function Index() {
|
|||
: "View Team"}
|
||||
</div>
|
||||
</li>
|
||||
{permissions === true ? (
|
||||
{permissions === true && (
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -176,7 +219,7 @@ export default function Index() {
|
|||
Create Sub-Collection
|
||||
</div>
|
||||
</li>
|
||||
) : undefined}
|
||||
)}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
|
@ -196,7 +239,7 @@ export default function Index() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{activeCollection ? (
|
||||
{activeCollection && (
|
||||
<div className={`min-w-[15rem]`}>
|
||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||
<div
|
||||
|
@ -232,18 +275,17 @@ export default function Index() {
|
|||
</div>
|
||||
<p className="text-neutral text-sm font-semibold">
|
||||
By {collectionOwner.name}
|
||||
{activeCollection.members.length > 0
|
||||
? ` and ${activeCollection.members.length} others`
|
||||
: undefined}
|
||||
{activeCollection.members.length > 0 &&
|
||||
` and ${activeCollection.members.length} others`}
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
)}
|
||||
|
||||
{activeCollection?.description ? (
|
||||
{activeCollection?.description && (
|
||||
<p>{activeCollection?.description}</p>
|
||||
) : undefined}
|
||||
)}
|
||||
|
||||
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
|
||||
<fieldset className="border rounded-md p-2 border-neutral-content">
|
||||
|
@ -272,16 +314,88 @@ export default function Index() {
|
|||
|
||||
<div className="divider my-0"></div>
|
||||
|
||||
<div className="flex justify-between items-end gap-5">
|
||||
<div className="flex justify-between items-center gap-5">
|
||||
<p>Showing {activeCollection?._count?.links} results</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{links.length > 0 &&
|
||||
(permissions === true ||
|
||||
permissions?.canUpdate ||
|
||||
permissions?.canDelete) && (
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
}}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
editMode
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||
</div>
|
||||
)}
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||
{links.length > 0 && (
|
||||
<div className="flex gap-3 ml-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length}{" "}
|
||||
{selectedLinks.length === 1 ? "link" : "links"} selected
|
||||
</span>
|
||||
) : (
|
||||
<span>Nothing selected</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
className="btn btn-sm btn-accent text-white w-fit ml-auto"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(permissions === true || permissions?.canUpdate)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
e.shiftKey
|
||||
? bulkDeleteLinks()
|
||||
: setBulkDeleteLinksModal(true);
|
||||
}}
|
||||
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(permissions === true || permissions?.canDelete)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
|
||||
<LinkComponent
|
||||
editMode={editMode}
|
||||
links={links.filter(
|
||||
(e) => e.collection.id === activeCollection?.id
|
||||
)}
|
||||
|
@ -290,34 +404,48 @@ export default function Index() {
|
|||
<NoLinksFound />
|
||||
)}
|
||||
</div>
|
||||
{activeCollection ? (
|
||||
{activeCollection && (
|
||||
<>
|
||||
{editCollectionModal ? (
|
||||
{editCollectionModal && (
|
||||
<EditCollectionModal
|
||||
onClose={() => setEditCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
) : undefined}
|
||||
{editCollectionSharingModal ? (
|
||||
)}
|
||||
{editCollectionSharingModal && (
|
||||
<EditCollectionSharingModal
|
||||
onClose={() => setEditCollectionSharingModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
) : undefined}
|
||||
{newCollectionModal ? (
|
||||
)}
|
||||
{newCollectionModal && (
|
||||
<NewCollectionModal
|
||||
onClose={() => setNewCollectionModal(false)}
|
||||
parent={activeCollection}
|
||||
/>
|
||||
) : undefined}
|
||||
{deleteCollectionModal ? (
|
||||
)}
|
||||
{deleteCollectionModal && (
|
||||
<DeleteCollectionModal
|
||||
onClose={() => setDeleteCollectionModal(false)}
|
||||
activeCollection={activeCollection}
|
||||
/>
|
||||
) : undefined}
|
||||
)}
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => {
|
||||
setBulkDeleteLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal
|
||||
onClose={() => {
|
||||
setBulkEditLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -168,7 +168,10 @@ export default function Dashboard() {
|
|||
>
|
||||
{links[0] ? (
|
||||
<div className="w-full">
|
||||
<LinkComponent links={links.slice(0, showLinks)} />
|
||||
<LinkComponent
|
||||
links={links.slice(0, showLinks)}
|
||||
showCheckbox={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
|
@ -279,6 +282,7 @@ export default function Dashboard() {
|
|||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<div className="w-full">
|
||||
<LinkComponent
|
||||
showCheckbox={false}
|
||||
links={links
|
||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||
.slice(0, showLinks)}
|
||||
|
|
|
@ -3,24 +3,74 @@ import SortDropdown from "@/components/SortDropdown";
|
|||
import useLinks from "@/hooks/useLinks";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import { Sort, ViewMode } from "@/types/global";
|
||||
import { Member, Sort, ViewMode } from "@/types/global";
|
||||
import ViewDropdown from "@/components/ViewDropdown";
|
||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
|
||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function Links() {
|
||||
const { links } = useLinkStore();
|
||||
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||
useLinkStore();
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
);
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
|
||||
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setEditMode(false);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const collectivePermissions = useCollectivePermissions(
|
||||
selectedLinks.map((link) => link.collectionId as number)
|
||||
);
|
||||
|
||||
useLinks({ sort: sortBy });
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLinks.length === links.length) {
|
||||
setSelectedLinks([]);
|
||||
} else {
|
||||
setSelectedLinks(links.map((link) => link));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(
|
||||
`Deleting ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}...`
|
||||
);
|
||||
|
||||
const response = await deleteLinksById(
|
||||
selectedLinks.map((link) => link.id as number)
|
||||
);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
response.ok &&
|
||||
toast.success(
|
||||
`Deleted ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}!`
|
||||
);
|
||||
};
|
||||
|
||||
const linkView = {
|
||||
[ViewMode.Card]: CardView,
|
||||
// [ViewMode.Grid]: GridView,
|
||||
|
@ -41,17 +91,105 @@ export default function Links() {
|
|||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
{links.length > 0 && (
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
}}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
editMode
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||
</div>
|
||||
)}
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||
{links.length > 0 && (
|
||||
<div className="flex gap-3 ml-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length}{" "}
|
||||
{selectedLinks.length === 1 ? "link" : "links"} selected
|
||||
</span>
|
||||
) : (
|
||||
<span>Nothing selected</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
className="btn btn-sm btn-accent text-white w-fit ml-auto"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canUpdate
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
e.shiftKey
|
||||
? bulkDeleteLinks()
|
||||
: setBulkDeleteLinksModal(true);
|
||||
}}
|
||||
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canDelete
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links[0] ? (
|
||||
<LinkComponent links={links} />
|
||||
<LinkComponent editMode={editMode} links={links} />
|
||||
) : (
|
||||
<NoLinksFound text="You Haven't Created Any Links Yet" />
|
||||
)}
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => {
|
||||
setBulkDeleteLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal
|
||||
onClose={() => {
|
||||
setBulkEditLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,16 +2,22 @@ import SortDropdown from "@/components/SortDropdown";
|
|||
import useLinks from "@/hooks/useLinks";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useLinkStore from "@/store/links";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import { Sort, ViewMode } from "@/types/global";
|
||||
import ViewDropdown from "@/components/ViewDropdown";
|
||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
|
||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||
import toast from "react-hot-toast";
|
||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function PinnedLinks() {
|
||||
const { links } = useLinkStore();
|
||||
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||
useLinkStore();
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
|
@ -20,6 +26,49 @@ export default function PinnedLinks() {
|
|||
|
||||
useLinks({ sort: sortBy, pinnedOnly: true });
|
||||
|
||||
const router = useRouter();
|
||||
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
|
||||
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setEditMode(false);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const collectivePermissions = useCollectivePermissions(
|
||||
selectedLinks.map((link) => link.collectionId as number)
|
||||
);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLinks.length === links.length) {
|
||||
setSelectedLinks([]);
|
||||
} else {
|
||||
setSelectedLinks(links.map((link) => link));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(
|
||||
`Deleting ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}...`
|
||||
);
|
||||
|
||||
const response = await deleteLinksById(
|
||||
selectedLinks.map((link) => link.id as number)
|
||||
);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
response.ok &&
|
||||
toast.success(
|
||||
`Deleted ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}!`
|
||||
);
|
||||
};
|
||||
|
||||
const linkView = {
|
||||
[ViewMode.Card]: CardView,
|
||||
// [ViewMode.Grid]: GridView,
|
||||
|
@ -39,13 +88,87 @@ export default function PinnedLinks() {
|
|||
description={"Pinned Links from your Collections"}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
{!(links.length === 0) && (
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
}}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
editMode
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||
</div>
|
||||
)}
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||
{links.length > 0 && (
|
||||
<div className="flex gap-3 ml-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length}{" "}
|
||||
{selectedLinks.length === 1 ? "link" : "links"} selected
|
||||
</span>
|
||||
) : (
|
||||
<span>Nothing selected</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
className="btn btn-sm btn-accent text-white w-fit ml-auto"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canUpdate
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
e.shiftKey
|
||||
? bulkDeleteLinks()
|
||||
: setBulkDeleteLinksModal(true);
|
||||
}}
|
||||
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canDelete
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||
<LinkComponent links={links} />
|
||||
<LinkComponent editMode={editMode} links={links} />
|
||||
) : (
|
||||
<div
|
||||
style={{ flex: "1 1 auto" }}
|
||||
|
@ -62,6 +185,20 @@ export default function PinnedLinks() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => {
|
||||
setBulkDeleteLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal
|
||||
onClose={() => {
|
||||
setBulkEditLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import useLinkStore from "@/store/links";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { FormEvent, use, useEffect, useState } from "react";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import useTagStore from "@/store/tags";
|
||||
import SortDropdown from "@/components/SortDropdown";
|
||||
|
@ -12,11 +12,15 @@ import CardView from "@/components/LinkViews/Layouts/CardView";
|
|||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
|
||||
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
|
||||
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
const { links } = useLinkStore();
|
||||
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||
useLinkStore();
|
||||
const { tags, updateTag, removeTag } = useTagStore();
|
||||
|
||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||
|
@ -26,11 +30,31 @@ export default function Index() {
|
|||
|
||||
const [activeTag, setActiveTag] = useState<TagIncludingLinkCount>();
|
||||
|
||||
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
|
||||
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setEditMode(false);
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const collectivePermissions = useCollectivePermissions(
|
||||
selectedLinks.map((link) => link.collectionId as number)
|
||||
);
|
||||
|
||||
useLinks({ tagId: Number(router.query.id), sort: sortBy });
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTag(tags.find((e) => e.id === Number(router.query.id)));
|
||||
}, [router, tags]);
|
||||
const tag = tags.find((e) => e.id === Number(router.query.id));
|
||||
|
||||
if (tags.length > 0 && !tag?.id) {
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveTag(tag);
|
||||
}, [router, tags, Number(router.query.id), setActiveTag]);
|
||||
|
||||
useEffect(() => {
|
||||
setNewTagName(activeTag?.name);
|
||||
|
@ -91,6 +115,35 @@ export default function Index() {
|
|||
setRenameTag(false);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLinks.length === links.length) {
|
||||
setSelectedLinks([]);
|
||||
} else {
|
||||
setSelectedLinks(links.map((link) => link));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteLinks = async () => {
|
||||
const load = toast.loading(
|
||||
`Deleting ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}...`
|
||||
);
|
||||
|
||||
const response = await deleteLinksById(
|
||||
selectedLinks.map((link) => link.id as number)
|
||||
);
|
||||
|
||||
toast.dismiss(load);
|
||||
|
||||
response.ok &&
|
||||
toast.success(
|
||||
`Deleted ${selectedLinks.length} Link${
|
||||
selectedLinks.length > 1 ? "s" : ""
|
||||
}!`
|
||||
);
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<string>(
|
||||
localStorage.getItem("viewMode") || ViewMode.Card
|
||||
);
|
||||
|
@ -195,16 +248,102 @@ export default function Index() {
|
|||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center mt-2">
|
||||
<div
|
||||
role="button"
|
||||
onClick={() => {
|
||||
setEditMode(!editMode);
|
||||
setSelectedLinks([]);
|
||||
}}
|
||||
className={`btn btn-square btn-sm btn-ghost ${
|
||||
editMode
|
||||
? "bg-primary/20 hover:bg-primary/20"
|
||||
: "hover:bg-neutral/20"
|
||||
}`}
|
||||
>
|
||||
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||
</div>
|
||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
{editMode && (
|
||||
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||
{links.length > 0 && (
|
||||
<div className="flex gap-3 ml-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
onChange={() => handleSelectAll()}
|
||||
checked={
|
||||
selectedLinks.length === links.length && links.length > 0
|
||||
}
|
||||
/>
|
||||
{selectedLinks.length > 0 ? (
|
||||
<span>
|
||||
{selectedLinks.length}{" "}
|
||||
{selectedLinks.length === 1 ? "link" : "links"} selected
|
||||
</span>
|
||||
) : (
|
||||
<span>Nothing selected</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setBulkEditLinksModal(true)}
|
||||
className="btn btn-sm btn-accent text-white w-fit ml-auto"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canUpdate
|
||||
)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
(document?.activeElement as HTMLElement)?.blur();
|
||||
e.shiftKey
|
||||
? bulkDeleteLinks()
|
||||
: setBulkDeleteLinksModal(true);
|
||||
}}
|
||||
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
|
||||
disabled={
|
||||
selectedLinks.length === 0 ||
|
||||
!(
|
||||
collectivePermissions === true ||
|
||||
collectivePermissions?.canDelete
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<LinkComponent
|
||||
editMode={editMode}
|
||||
links={links.filter((e) =>
|
||||
e.tags.some((e) => e.id === Number(router.query.id))
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{bulkDeleteLinksModal && (
|
||||
<BulkDeleteLinksModal
|
||||
onClose={() => {
|
||||
setBulkDeleteLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{bulkEditLinksModal && (
|
||||
<BulkEditLinksModal
|
||||
onClose={() => {
|
||||
setBulkEditLinksModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -10,10 +10,12 @@ type ResponseObject = {
|
|||
|
||||
type LinkStore = {
|
||||
links: LinkIncludingShortenedCollectionAndTags[];
|
||||
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
|
||||
setLinks: (
|
||||
data: LinkIncludingShortenedCollectionAndTags[],
|
||||
isInitialCall: boolean
|
||||
) => void;
|
||||
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
|
||||
addLink: (
|
||||
body: LinkIncludingShortenedCollectionAndTags
|
||||
) => Promise<ResponseObject>;
|
||||
|
@ -21,12 +23,22 @@ type LinkStore = {
|
|||
updateLink: (
|
||||
link: LinkIncludingShortenedCollectionAndTags
|
||||
) => Promise<ResponseObject>;
|
||||
updateLinks: (
|
||||
links: LinkIncludingShortenedCollectionAndTags[],
|
||||
removePreviousTags: boolean,
|
||||
newData: Pick<
|
||||
LinkIncludingShortenedCollectionAndTags,
|
||||
"tags" | "collectionId"
|
||||
>
|
||||
) => Promise<ResponseObject>;
|
||||
removeLink: (linkId: number) => Promise<ResponseObject>;
|
||||
deleteLinksById: (linkIds: number[]) => Promise<ResponseObject>;
|
||||
resetLinks: () => void;
|
||||
};
|
||||
|
||||
const useLinkStore = create<LinkStore>()((set) => ({
|
||||
links: [],
|
||||
selectedLinks: [],
|
||||
setLinks: async (data, isInitialCall) => {
|
||||
isInitialCall &&
|
||||
set(() => ({
|
||||
|
@ -45,6 +57,7 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||
),
|
||||
}));
|
||||
},
|
||||
setSelectedLinks: (links) => set({ selectedLinks: links }),
|
||||
addLink: async (body) => {
|
||||
const response = await fetch("/api/v1/links", {
|
||||
body: JSON.stringify(body),
|
||||
|
@ -122,6 +135,41 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
updateLinks: async (links, removePreviousTags, newData) => {
|
||||
const response = await fetch("/api/v1/links", {
|
||||
body: JSON.stringify({ links, removePreviousTags, newData }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
set((state) => ({
|
||||
links: state.links.map((e) =>
|
||||
links.some((link) => link.id === e.id)
|
||||
? {
|
||||
...e,
|
||||
collectionId: newData.collectionId ?? e.collectionId,
|
||||
collection: {
|
||||
...e.collection,
|
||||
id: newData.collectionId ?? e.collection.id,
|
||||
},
|
||||
tags: removePreviousTags
|
||||
? [...(newData.tags ?? [])]
|
||||
: [...e.tags, ...(newData.tags ?? [])],
|
||||
}
|
||||
: e
|
||||
),
|
||||
}));
|
||||
useTagStore.getState().setTags();
|
||||
useCollectionStore.getState().setCollections();
|
||||
}
|
||||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
removeLink: async (linkId) => {
|
||||
const response = await fetch(`/api/v1/links/${linkId}`, {
|
||||
headers: {
|
||||
|
@ -142,6 +190,27 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
deleteLinksById: async (linkIds: number[]) => {
|
||||
const response = await fetch("/api/v1/links", {
|
||||
body: JSON.stringify({ linkIds }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
set((state) => ({
|
||||
links: state.links.filter((e) => !linkIds.includes(e.id as number)),
|
||||
}));
|
||||
useTagStore.getState().setTags();
|
||||
useCollectionStore.getState().setCollections();
|
||||
}
|
||||
|
||||
return { ok: response.ok, data: data.response };
|
||||
},
|
||||
resetLinks: () => set({ links: [] }),
|
||||
}));
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { create } from "zustand";
|
||||
import { ViewMode } from "@/types/global";
|
||||
|
||||
type LocalSettings = {
|
||||
theme?: string;
|
||||
|
|
Ŝarĝante…
Reference in New Issue