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 { styles } from "./styles";
|
||||||
import { Options } from "./types";
|
import { Options } from "./types";
|
||||||
import CreatableSelect from "react-select/creatable";
|
import CreatableSelect from "react-select/creatable";
|
||||||
|
import Select from "react-select";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChange: any;
|
onChange: any;
|
||||||
defaultValue:
|
showDefaultValue?: boolean;
|
||||||
|
defaultValue?:
|
||||||
| {
|
| {
|
||||||
label: string;
|
label: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
}
|
}
|
||||||
| undefined;
|
| 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 { collections } = useCollectionStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
@ -42,6 +50,7 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
||||||
setOptions(formatedCollections);
|
setOptions(formatedCollections);
|
||||||
}, [collections]);
|
}, [collections]);
|
||||||
|
|
||||||
|
if (creatable) {
|
||||||
return (
|
return (
|
||||||
<CreatableSelect
|
<CreatableSelect
|
||||||
isClearable={false}
|
isClearable={false}
|
||||||
|
@ -50,8 +59,22 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={options}
|
options={options}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
defaultValue={defaultValue}
|
defaultValue={showDefaultValue ? defaultValue : null}
|
||||||
// menuPosition="fixed"
|
// 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({
|
export default function CardView({
|
||||||
links,
|
links,
|
||||||
|
showCheckbox = true,
|
||||||
|
editMode,
|
||||||
}: {
|
}: {
|
||||||
links: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
showCheckbox?: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
<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}
|
link={e}
|
||||||
count={i}
|
count={i}
|
||||||
flipDropdown={i === links.length - 1}
|
flipDropdown={i === links.length - 1}
|
||||||
|
editMode={editMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -3,11 +3,13 @@ import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
|
||||||
export default function ListView({
|
export default function ListView({
|
||||||
links,
|
links,
|
||||||
|
editMode,
|
||||||
}: {
|
}: {
|
||||||
links: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
editMode?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex gap-1 flex-col">
|
||||||
{links.map((e, i) => {
|
{links.map((e, i) => {
|
||||||
return (
|
return (
|
||||||
<LinkList
|
<LinkList
|
||||||
|
@ -15,6 +17,7 @@ export default function ListView({
|
||||||
link={e}
|
link={e}
|
||||||
count={i}
|
count={i}
|
||||||
flipDropdown={i === links.length - 1}
|
flipDropdown={i === links.length - 1}
|
||||||
|
editMode={editMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -17,22 +17,32 @@ import LinkIcon from "./LinkComponents/LinkIcon";
|
||||||
import useOnScreen from "@/hooks/useOnScreen";
|
import useOnScreen from "@/hooks/useOnScreen";
|
||||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||||
import useAccountStore from "@/store/account";
|
import useAccountStore from "@/store/account";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
count: number;
|
count: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
flipDropdown?: boolean;
|
flipDropdown?: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkGrid({
|
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
||||||
link,
|
|
||||||
flipDropdown,
|
|
||||||
}: Props) {
|
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
const { account } = useAccountStore();
|
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;
|
let shortendURL;
|
||||||
|
|
||||||
|
@ -59,6 +69,7 @@ export default function LinkGrid({
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const isVisible = useOnScreen(ref);
|
const isVisible = useOnScreen(ref);
|
||||||
|
const permissions = usePermissions(collection?.id as number);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: any;
|
let interval: any;
|
||||||
|
@ -82,11 +93,32 @@ export default function LinkGrid({
|
||||||
|
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
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
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{!editMode ? (
|
||||||
|
<>
|
||||||
<Link
|
<Link
|
||||||
href={generateLinkHref(link, account)}
|
href={generateLinkHref(link, account)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
@ -112,15 +144,7 @@ export default function LinkGrid({
|
||||||
) : (
|
) : (
|
||||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<LinkIcon link={link} />
|
<LinkIcon link={link} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -144,15 +168,15 @@ export default function LinkGrid({
|
||||||
|
|
||||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||||
<div className="cursor-pointer w-fit">
|
<div className="cursor-pointer w-fit">
|
||||||
{collection ? (
|
{collection && (
|
||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection link={link} collection={collection} />
|
||||||
) : undefined}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<LinkDate link={link} />
|
<LinkDate link={link} />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{showInfo ? (
|
{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 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
|
<div
|
||||||
onClick={() => setShowInfo(!showInfo)}
|
onClick={() => setShowInfo(!showInfo)}
|
||||||
|
@ -172,9 +196,11 @@ export default function LinkGrid({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{link.tags[0] ? (
|
{link.tags[0] && (
|
||||||
<>
|
<>
|
||||||
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
|
<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]" />
|
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
|
||||||
|
@ -195,9 +221,9 @@ export default function LinkGrid({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
)}
|
||||||
|
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
|
@ -207,6 +233,121 @@ export default function LinkGrid({
|
||||||
linkInfo={showInfo}
|
linkInfo={showInfo}
|
||||||
flipDropdown={flipDropdown}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,7 +101,7 @@ export default function LinkGrid({ link }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LinkActions
|
<LinkActions
|
||||||
toggleShowInfo={() => { }}
|
toggleShowInfo={() => {}}
|
||||||
linkInfo={false}
|
linkInfo={false}
|
||||||
link={link}
|
link={link}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
|
|
|
@ -14,21 +14,41 @@ import Link from "next/link";
|
||||||
import { isPWA } from "@/lib/client/utils";
|
import { isPWA } from "@/lib/client/utils";
|
||||||
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||||
import useAccountStore from "@/store/account";
|
import useAccountStore from "@/store/account";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
count: number;
|
count: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
flipDropdown?: boolean;
|
flipDropdown?: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkCardCompact({
|
export default function LinkCardCompact({
|
||||||
link,
|
link,
|
||||||
flipDropdown,
|
flipDropdown,
|
||||||
|
editMode,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
const { account } = useAccountStore();
|
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;
|
let shortendURL;
|
||||||
|
|
||||||
|
@ -53,18 +73,56 @@ export default function LinkCardCompact({
|
||||||
);
|
);
|
||||||
}, [collections, links]);
|
}, [collections, links]);
|
||||||
|
|
||||||
|
const permissions = usePermissions(collection?.id as number);
|
||||||
|
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`border-neutral-content relative ${!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
|
className={`${selectedStyle} border relative items-center flex ${
|
||||||
|
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
|
||||||
} duration-200 rounded-lg`}
|
} duration-200 rounded-lg`}
|
||||||
|
onClick={() =>
|
||||||
|
selectable
|
||||||
|
? handleCheckboxClick(link)
|
||||||
|
: editMode
|
||||||
|
? toast.error(
|
||||||
|
"You don't have permission to edit or delete this item."
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
{/* {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
|
<Link
|
||||||
href={generateLinkHref(link, account)}
|
href={generateLinkHref(link, account)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex items-start cursor-pointer"
|
className="flex items-center cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
||||||
|
@ -77,9 +135,9 @@ export default function LinkCardCompact({
|
||||||
|
|
||||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
<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">
|
<div className="flex items-center gap-x-3 w-fit text-neutral flex-wrap">
|
||||||
{collection ? (
|
{collection && (
|
||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection link={link} collection={collection} />
|
||||||
) : undefined}
|
)}
|
||||||
{link.url ? (
|
{link.url ? (
|
||||||
<div className="flex items-center gap-1 w-fit text-neutral truncate">
|
<div className="flex items-center gap-1 w-fit text-neutral truncate">
|
||||||
<i className="bi-link-45deg text-lg" />
|
<i className="bi-link-45deg text-lg" />
|
||||||
|
@ -95,7 +153,6 @@ export default function LinkCardCompact({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
|
@ -104,10 +161,12 @@ export default function LinkCardCompact({
|
||||||
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||||
// linkInfo={showInfo}
|
// linkInfo={showInfo}
|
||||||
/>
|
/>
|
||||||
{showInfo ? (
|
{showInfo && (
|
||||||
<div>
|
<div>
|
||||||
<div className="pb-3 mt-1 px-3">
|
<div className="pb-3 mt-1 px-3">
|
||||||
<p className="text-neutral text-lg font-semibold">Description</p>
|
<p className="text-neutral text-lg font-semibold">
|
||||||
|
Description
|
||||||
|
</p>
|
||||||
|
|
||||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
<p>
|
<p>
|
||||||
|
@ -119,7 +178,7 @@ export default function LinkCardCompact({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{link.tags[0] ? (
|
{link.tags[0] && (
|
||||||
<>
|
<>
|
||||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||||
Tags
|
Tags
|
||||||
|
@ -144,12 +203,56 @@ export default function LinkCardCompact({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
<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);
|
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||||
|
|
||||||
const { removeLink } = useLinkStore();
|
const { removeLink } = useLinkStore();
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
|
@ -234,11 +234,8 @@ export default function EditCollectionSharingModal({
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<React.Fragment key={i}>
|
||||||
<div
|
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none">
|
||||||
key={i}
|
|
||||||
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={"flex items-center justify-between w-full"}
|
className={"flex items-center justify-between w-full"}
|
||||||
>
|
>
|
||||||
|
@ -433,7 +430,7 @@ export default function EditCollectionSharingModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider my-0 last:hidden h-[3px]"></div>
|
<div className="divider my-0 last:hidden h-[3px]"></div>
|
||||||
</>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -124,6 +124,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
||||||
label: "Unorganized",
|
label: "Unorganized",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
creatable={false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,7 +14,7 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
onMouseDown={dropdownTriggerer}
|
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>
|
<i className="bi-chevron-expand text-neutral text-2xl"></i>
|
||||||
</div>
|
</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 useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
|
||||||
import { ViewMode } from "@/types/global";
|
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,
|
searchByTextContent,
|
||||||
}: LinkRequestQuery = { sort: 0 }
|
}: LinkRequestQuery = { sort: 0 }
|
||||||
) {
|
) {
|
||||||
const { links, setLinks, resetLinks } = useLinkStore();
|
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } =
|
||||||
|
useLinkStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
|
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
|
||||||
|
@ -68,8 +69,12 @@ export default function useLinks(
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Save the selected links before resetting the links
|
||||||
|
// and then restore the selected links after resetting the links
|
||||||
|
const previouslySelected = selectedLinks;
|
||||||
resetLinks();
|
resetLinks();
|
||||||
|
|
||||||
|
setSelectedLinks(previouslySelected);
|
||||||
getLinks(true);
|
getLinks(true);
|
||||||
}, [
|
}, [
|
||||||
router,
|
router,
|
||||||
|
|
|
@ -23,8 +23,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||||
const browser = await chromium.launch();
|
const browser = await chromium.launch();
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
ignoreHTTPSErrors:
|
ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true",
|
||||||
process.env.IGNORE_HTTPS_ERRORS === "true",
|
|
||||||
});
|
});
|
||||||
const page = await context.newPage();
|
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 { 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 getPermission from "@/lib/api/getPermission";
|
||||||
import removeFile from "@/lib/api/storage/removeFile";
|
import removeFile from "@/lib/api/storage/removeFile";
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import { Collection, Link, UsersAndCollections } from "@prisma/client";
|
import { UsersAndCollections } from "@prisma/client";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import moveFile from "@/lib/api/storage/moveFile";
|
import moveFile from "@/lib/api/storage/moveFile";
|
||||||
|
|
||||||
|
@ -16,6 +16,10 @@ export default async function updateLinkById(
|
||||||
};
|
};
|
||||||
|
|
||||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||||
|
const targetCollectionIsAccessible = await getPermission({
|
||||||
|
userId,
|
||||||
|
collectionId: data.collection.id,
|
||||||
|
});
|
||||||
|
|
||||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||||
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
|
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
|
||||||
|
@ -25,6 +29,28 @@ export default async function updateLinkById(
|
||||||
collectionIsAccessible?.ownerId === data.collection.ownerId &&
|
collectionIsAccessible?.ownerId === data.collection.ownerId &&
|
||||||
data.collection.ownerId === userId;
|
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 =
|
const unauthorizedSwitchCollection =
|
||||||
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
|
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
|
||||||
|
|
||||||
|
|
|
@ -3,12 +3,14 @@ import { prisma } from "@/lib/api/db";
|
||||||
type Props = {
|
type Props = {
|
||||||
userId: number;
|
userId: number;
|
||||||
collectionId?: number;
|
collectionId?: number;
|
||||||
|
collectionName?: string;
|
||||||
linkId?: number;
|
linkId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function getPermission({
|
export default async function getPermission({
|
||||||
userId,
|
userId,
|
||||||
collectionId,
|
collectionId,
|
||||||
|
collectionName,
|
||||||
linkId,
|
linkId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (linkId) {
|
if (linkId) {
|
||||||
|
@ -24,10 +26,11 @@ export default async function getPermission({
|
||||||
});
|
});
|
||||||
|
|
||||||
return check;
|
return check;
|
||||||
} else if (collectionId) {
|
} else if (collectionId || collectionName) {
|
||||||
const check = await prisma.collection.findFirst({
|
const check = await prisma.collection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: collectionId,
|
id: collectionId || undefined,
|
||||||
|
name: collectionName || undefined,
|
||||||
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
||||||
},
|
},
|
||||||
include: { members: true },
|
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 { LinksRouteTo } from "@prisma/client";
|
||||||
import { pdfAvailable, readabilityAvailable, screenshotAvailable } from "../shared/getArchiveValidity";
|
import {
|
||||||
|
pdfAvailable,
|
||||||
export const generateLinkHref = (link: LinkIncludingShortenedCollectionAndTags, account: AccountSettings): string => {
|
readabilityAvailable,
|
||||||
|
screenshotAvailable,
|
||||||
|
} from "../shared/getArchiveValidity";
|
||||||
|
|
||||||
|
export const generateLinkHref = (
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags,
|
||||||
|
account: AccountSettings
|
||||||
|
): string => {
|
||||||
// Return the links href based on the account's preference
|
// Return the links href based on the account's preference
|
||||||
// If the user's preference is not available, return the original link
|
// If the user's preference is not available, return the original link
|
||||||
switch (account.linksRouteTo) {
|
switch (account.linksRouteTo) {
|
||||||
case LinksRouteTo.ORIGINAL:
|
case LinksRouteTo.ORIGINAL:
|
||||||
return link.url || '';
|
return link.url || "";
|
||||||
case LinksRouteTo.PDF:
|
case LinksRouteTo.PDF:
|
||||||
if (!pdfAvailable(link)) return link.url || '';
|
if (!pdfAvailable(link)) return link.url || "";
|
||||||
|
|
||||||
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
|
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
|
||||||
case LinksRouteTo.READABLE:
|
case LinksRouteTo.READABLE:
|
||||||
if (!readabilityAvailable(link)) return link.url || '';
|
if (!readabilityAvailable(link)) return link.url || "";
|
||||||
|
|
||||||
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
|
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
|
||||||
case LinksRouteTo.SCREENSHOT:
|
case LinksRouteTo.SCREENSHOT:
|
||||||
if (!screenshotAvailable(link)) return link.url || '';
|
if (!screenshotAvailable(link)) return link.url || "";
|
||||||
|
|
||||||
return `/preserved/${link?.id}?format=${link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg}`;
|
return `/preserved/${link?.id}?format=${
|
||||||
|
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
|
||||||
|
}`;
|
||||||
default:
|
default:
|
||||||
return link.url || '';
|
return link.url || "";
|
||||||
}
|
}
|
||||||
};
|
};
|
|
@ -1,6 +1,8 @@
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
|
||||||
export function screenshotAvailable(link: LinkIncludingShortenedCollectionAndTags) {
|
export function screenshotAvailable(
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
link &&
|
link &&
|
||||||
link.image &&
|
link.image &&
|
||||||
|
@ -15,7 +17,9 @@ export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readabilityAvailable(link: LinkIncludingShortenedCollectionAndTags) {
|
export function readabilityAvailable(
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
link &&
|
link &&
|
||||||
link.readable &&
|
link.readable &&
|
||||||
|
|
|
@ -3,6 +3,8 @@ import getLinks from "@/lib/api/controllers/links/getLinks";
|
||||||
import postLink from "@/lib/api/controllers/links/postLink";
|
import postLink from "@/lib/api/controllers/links/postLink";
|
||||||
import { LinkRequestQuery } from "@/types/global";
|
import { LinkRequestQuery } from "@/types/global";
|
||||||
import verifyUser from "@/lib/api/verifyUser";
|
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) {
|
export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const user = await verifyUser({ req, res });
|
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({
|
return res.status(newlink.status).json({
|
||||||
response: newlink.response,
|
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 GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||||
import Link from "next/link";
|
|
||||||
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
|
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() {
|
export default function Index() {
|
||||||
const { settings } = useLocalSettingsStore();
|
const { settings } = useLocalSettingsStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { links } = useLinkStore();
|
const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
|
||||||
|
useLinkStore();
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||||
|
@ -81,6 +84,9 @@ export default function Index() {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchOwner();
|
fetchOwner();
|
||||||
|
|
||||||
|
// When the collection changes, reset the selected links
|
||||||
|
setSelectedLinks([]);
|
||||||
}, [activeCollection]);
|
}, [activeCollection]);
|
||||||
|
|
||||||
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
||||||
|
@ -88,6 +94,14 @@ export default function Index() {
|
||||||
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [deleteCollectionModal, setDeleteCollectionModal] = 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>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
localStorage.getItem("viewMode") || ViewMode.Card
|
localStorage.getItem("viewMode") || ViewMode.Card
|
||||||
|
@ -102,6 +116,35 @@ export default function Index() {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const LinkComponent = linkView[viewMode];
|
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 (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div
|
<div
|
||||||
|
@ -135,7 +178,7 @@ export default function Index() {
|
||||||
<i className="bi-three-dots text-xl" title="More"></i>
|
<i className="bi-three-dots text-xl" title="More"></i>
|
||||||
</div>
|
</div>
|
||||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
|
<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>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -148,7 +191,7 @@ export default function Index() {
|
||||||
Edit Collection Info
|
Edit Collection Info
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
) : undefined}
|
)}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -163,7 +206,7 @@ export default function Index() {
|
||||||
: "View Team"}
|
: "View Team"}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{permissions === true ? (
|
{permissions === true && (
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -176,7 +219,7 @@ export default function Index() {
|
||||||
Create Sub-Collection
|
Create Sub-Collection
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
) : undefined}
|
)}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -196,7 +239,7 @@ export default function Index() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeCollection ? (
|
{activeCollection && (
|
||||||
<div className={`min-w-[15rem]`}>
|
<div className={`min-w-[15rem]`}>
|
||||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||||
<div
|
<div
|
||||||
|
@ -232,18 +275,17 @@ export default function Index() {
|
||||||
</div>
|
</div>
|
||||||
<p className="text-neutral text-sm font-semibold">
|
<p className="text-neutral text-sm font-semibold">
|
||||||
By {collectionOwner.name}
|
By {collectionOwner.name}
|
||||||
{activeCollection.members.length > 0
|
{activeCollection.members.length > 0 &&
|
||||||
? ` and ${activeCollection.members.length} others`
|
` and ${activeCollection.members.length} others`}
|
||||||
: undefined}
|
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
)}
|
||||||
|
|
||||||
{activeCollection?.description ? (
|
{activeCollection?.description && (
|
||||||
<p>{activeCollection?.description}</p>
|
<p>{activeCollection?.description}</p>
|
||||||
) : undefined}
|
)}
|
||||||
|
|
||||||
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
|
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
|
||||||
<fieldset className="border rounded-md p-2 border-neutral-content">
|
<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="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>
|
<p>Showing {activeCollection?._count?.links} results</p>
|
||||||
<div className="flex items-center gap-2">
|
<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} />
|
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
</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)) ? (
|
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
|
||||||
<LinkComponent
|
<LinkComponent
|
||||||
|
editMode={editMode}
|
||||||
links={links.filter(
|
links={links.filter(
|
||||||
(e) => e.collection.id === activeCollection?.id
|
(e) => e.collection.id === activeCollection?.id
|
||||||
)}
|
)}
|
||||||
|
@ -290,34 +404,48 @@ export default function Index() {
|
||||||
<NoLinksFound />
|
<NoLinksFound />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeCollection ? (
|
{activeCollection && (
|
||||||
<>
|
<>
|
||||||
{editCollectionModal ? (
|
{editCollectionModal && (
|
||||||
<EditCollectionModal
|
<EditCollectionModal
|
||||||
onClose={() => setEditCollectionModal(false)}
|
onClose={() => setEditCollectionModal(false)}
|
||||||
activeCollection={activeCollection}
|
activeCollection={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
)}
|
||||||
{editCollectionSharingModal ? (
|
{editCollectionSharingModal && (
|
||||||
<EditCollectionSharingModal
|
<EditCollectionSharingModal
|
||||||
onClose={() => setEditCollectionSharingModal(false)}
|
onClose={() => setEditCollectionSharingModal(false)}
|
||||||
activeCollection={activeCollection}
|
activeCollection={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
)}
|
||||||
{newCollectionModal ? (
|
{newCollectionModal && (
|
||||||
<NewCollectionModal
|
<NewCollectionModal
|
||||||
onClose={() => setNewCollectionModal(false)}
|
onClose={() => setNewCollectionModal(false)}
|
||||||
parent={activeCollection}
|
parent={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
)}
|
||||||
{deleteCollectionModal ? (
|
{deleteCollectionModal && (
|
||||||
<DeleteCollectionModal
|
<DeleteCollectionModal
|
||||||
onClose={() => setDeleteCollectionModal(false)}
|
onClose={() => setDeleteCollectionModal(false)}
|
||||||
activeCollection={activeCollection}
|
activeCollection={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
)}
|
||||||
|
{bulkDeleteLinksModal && (
|
||||||
|
<BulkDeleteLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkDeleteLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkEditLinksModal && (
|
||||||
|
<BulkEditLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkEditLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
)}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -168,7 +168,10 @@ export default function Dashboard() {
|
||||||
>
|
>
|
||||||
{links[0] ? (
|
{links[0] ? (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<LinkComponent links={links.slice(0, showLinks)} />
|
<LinkComponent
|
||||||
|
links={links.slice(0, showLinks)}
|
||||||
|
showCheckbox={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
@ -279,6 +282,7 @@ export default function Dashboard() {
|
||||||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<LinkComponent
|
<LinkComponent
|
||||||
|
showCheckbox={false}
|
||||||
links={links
|
links={links
|
||||||
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
.filter((e) => e.pinnedBy && e.pinnedBy[0])
|
||||||
.slice(0, showLinks)}
|
.slice(0, showLinks)}
|
||||||
|
|
|
@ -3,24 +3,74 @@ import SortDropdown from "@/components/SortDropdown";
|
||||||
import useLinks from "@/hooks/useLinks";
|
import useLinks from "@/hooks/useLinks";
|
||||||
import MainLayout from "@/layouts/MainLayout";
|
import MainLayout from "@/layouts/MainLayout";
|
||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import { Sort, ViewMode } from "@/types/global";
|
import { Member, Sort, ViewMode } from "@/types/global";
|
||||||
import ViewDropdown from "@/components/ViewDropdown";
|
import ViewDropdown from "@/components/ViewDropdown";
|
||||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
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 GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
export default function Links() {
|
export default function Links() {
|
||||||
const { links } = useLinkStore();
|
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||||
|
useLinkStore();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<string>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
localStorage.getItem("viewMode") || ViewMode.Card
|
localStorage.getItem("viewMode") || ViewMode.Card
|
||||||
);
|
);
|
||||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
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 });
|
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 = {
|
const linkView = {
|
||||||
[ViewMode.Card]: CardView,
|
[ViewMode.Card]: CardView,
|
||||||
// [ViewMode.Grid]: GridView,
|
// [ViewMode.Grid]: GridView,
|
||||||
|
@ -41,17 +91,105 @@ export default function Links() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center justify-end gap-2">
|
<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} />
|
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
</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] ? (
|
{links[0] ? (
|
||||||
<LinkComponent links={links} />
|
<LinkComponent editMode={editMode} links={links} />
|
||||||
) : (
|
) : (
|
||||||
<NoLinksFound text="You Haven't Created Any Links Yet" />
|
<NoLinksFound text="You Haven't Created Any Links Yet" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{bulkDeleteLinksModal && (
|
||||||
|
<BulkDeleteLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkDeleteLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkEditLinksModal && (
|
||||||
|
<BulkEditLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkEditLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,22 @@ import SortDropdown from "@/components/SortDropdown";
|
||||||
import useLinks from "@/hooks/useLinks";
|
import useLinks from "@/hooks/useLinks";
|
||||||
import MainLayout from "@/layouts/MainLayout";
|
import MainLayout from "@/layouts/MainLayout";
|
||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import { Sort, ViewMode } from "@/types/global";
|
import { Sort, ViewMode } from "@/types/global";
|
||||||
import ViewDropdown from "@/components/ViewDropdown";
|
import ViewDropdown from "@/components/ViewDropdown";
|
||||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
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 GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
export default function PinnedLinks() {
|
export default function PinnedLinks() {
|
||||||
const { links } = useLinkStore();
|
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||||
|
useLinkStore();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<string>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
localStorage.getItem("viewMode") || ViewMode.Card
|
localStorage.getItem("viewMode") || ViewMode.Card
|
||||||
|
@ -20,6 +26,49 @@ export default function PinnedLinks() {
|
||||||
|
|
||||||
useLinks({ sort: sortBy, pinnedOnly: true });
|
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 = {
|
const linkView = {
|
||||||
[ViewMode.Card]: CardView,
|
[ViewMode.Card]: CardView,
|
||||||
// [ViewMode.Grid]: GridView,
|
// [ViewMode.Grid]: GridView,
|
||||||
|
@ -39,13 +88,87 @@ export default function PinnedLinks() {
|
||||||
description={"Pinned Links from your Collections"}
|
description={"Pinned Links from your Collections"}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-end gap-2">
|
<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} />
|
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
</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]) ? (
|
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||||
<LinkComponent links={links} />
|
<LinkComponent editMode={editMode} links={links} />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{ flex: "1 1 auto" }}
|
style={{ flex: "1 1 auto" }}
|
||||||
|
@ -62,6 +185,20 @@ export default function PinnedLinks() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{bulkDeleteLinksModal && (
|
||||||
|
<BulkDeleteLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkDeleteLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkEditLinksModal && (
|
||||||
|
<BulkEditLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkEditLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, use, useEffect, useState } from "react";
|
||||||
import MainLayout from "@/layouts/MainLayout";
|
import MainLayout from "@/layouts/MainLayout";
|
||||||
import useTagStore from "@/store/tags";
|
import useTagStore from "@/store/tags";
|
||||||
import SortDropdown from "@/components/SortDropdown";
|
import SortDropdown from "@/components/SortDropdown";
|
||||||
|
@ -12,11 +12,15 @@ import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
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() {
|
export default function Index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { links } = useLinkStore();
|
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||||
|
useLinkStore();
|
||||||
const { tags, updateTag, removeTag } = useTagStore();
|
const { tags, updateTag, removeTag } = useTagStore();
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||||
|
@ -26,11 +30,31 @@ export default function Index() {
|
||||||
|
|
||||||
const [activeTag, setActiveTag] = useState<TagIncludingLinkCount>();
|
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 });
|
useLinks({ tagId: Number(router.query.id), sort: sortBy });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTag(tags.find((e) => e.id === Number(router.query.id)));
|
const tag = tags.find((e) => e.id === Number(router.query.id));
|
||||||
}, [router, tags]);
|
|
||||||
|
if (tags.length > 0 && !tag?.id) {
|
||||||
|
router.push("/dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTag(tag);
|
||||||
|
}, [router, tags, Number(router.query.id), setActiveTag]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNewTagName(activeTag?.name);
|
setNewTagName(activeTag?.name);
|
||||||
|
@ -91,6 +115,35 @@ export default function Index() {
|
||||||
setRenameTag(false);
|
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>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
localStorage.getItem("viewMode") || ViewMode.Card
|
localStorage.getItem("viewMode") || ViewMode.Card
|
||||||
);
|
);
|
||||||
|
@ -195,16 +248,102 @@ export default function Index() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center mt-2">
|
<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} />
|
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
</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
|
<LinkComponent
|
||||||
|
editMode={editMode}
|
||||||
links={links.filter((e) =>
|
links={links.filter((e) =>
|
||||||
e.tags.some((e) => e.id === Number(router.query.id))
|
e.tags.some((e) => e.id === Number(router.query.id))
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{bulkDeleteLinksModal && (
|
||||||
|
<BulkDeleteLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkDeleteLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkEditLinksModal && (
|
||||||
|
<BulkEditLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkEditLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,12 @@ type ResponseObject = {
|
||||||
|
|
||||||
type LinkStore = {
|
type LinkStore = {
|
||||||
links: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
|
||||||
setLinks: (
|
setLinks: (
|
||||||
data: LinkIncludingShortenedCollectionAndTags[],
|
data: LinkIncludingShortenedCollectionAndTags[],
|
||||||
isInitialCall: boolean
|
isInitialCall: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
|
||||||
addLink: (
|
addLink: (
|
||||||
body: LinkIncludingShortenedCollectionAndTags
|
body: LinkIncludingShortenedCollectionAndTags
|
||||||
) => Promise<ResponseObject>;
|
) => Promise<ResponseObject>;
|
||||||
|
@ -21,12 +23,22 @@ type LinkStore = {
|
||||||
updateLink: (
|
updateLink: (
|
||||||
link: LinkIncludingShortenedCollectionAndTags
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
) => Promise<ResponseObject>;
|
) => Promise<ResponseObject>;
|
||||||
|
updateLinks: (
|
||||||
|
links: LinkIncludingShortenedCollectionAndTags[],
|
||||||
|
removePreviousTags: boolean,
|
||||||
|
newData: Pick<
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
"tags" | "collectionId"
|
||||||
|
>
|
||||||
|
) => Promise<ResponseObject>;
|
||||||
removeLink: (linkId: number) => Promise<ResponseObject>;
|
removeLink: (linkId: number) => Promise<ResponseObject>;
|
||||||
|
deleteLinksById: (linkIds: number[]) => Promise<ResponseObject>;
|
||||||
resetLinks: () => void;
|
resetLinks: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useLinkStore = create<LinkStore>()((set) => ({
|
const useLinkStore = create<LinkStore>()((set) => ({
|
||||||
links: [],
|
links: [],
|
||||||
|
selectedLinks: [],
|
||||||
setLinks: async (data, isInitialCall) => {
|
setLinks: async (data, isInitialCall) => {
|
||||||
isInitialCall &&
|
isInitialCall &&
|
||||||
set(() => ({
|
set(() => ({
|
||||||
|
@ -45,6 +57,7 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
setSelectedLinks: (links) => set({ selectedLinks: links }),
|
||||||
addLink: async (body) => {
|
addLink: async (body) => {
|
||||||
const response = await fetch("/api/v1/links", {
|
const response = await fetch("/api/v1/links", {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
@ -122,6 +135,41 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
||||||
|
|
||||||
return { ok: response.ok, data: data.response };
|
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) => {
|
removeLink: async (linkId) => {
|
||||||
const response = await fetch(`/api/v1/links/${linkId}`, {
|
const response = await fetch(`/api/v1/links/${linkId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -142,6 +190,27 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
||||||
|
|
||||||
return { ok: response.ok, data: data.response };
|
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: [] }),
|
resetLinks: () => set({ links: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { ViewMode } from "@/types/global";
|
|
||||||
|
|
||||||
type LocalSettings = {
|
type LocalSettings = {
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
|
Ŝarĝante…
Reference in New Issue