Merge pull request #457 from linkwarden/feat/sub-collections
Feat/sub collections
This commit is contained in:
commit
39e022f87b
|
@ -0,0 +1,160 @@
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
links: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollectionSelection = ({ links }: Props) => {
|
||||||
|
const { collections } = useCollectionStore();
|
||||||
|
const [active, setActive] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActive(router.asPath);
|
||||||
|
}, [router, collections]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{collections[0] ? (
|
||||||
|
collections
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.filter((e) => e.parentId === null)
|
||||||
|
.map((e, i) => (
|
||||||
|
<CollectionItem
|
||||||
|
key={i}
|
||||||
|
collection={e}
|
||||||
|
active={active}
|
||||||
|
collections={collections}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
||||||
|
>
|
||||||
|
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
|
||||||
|
You Have No Collections...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionSelection;
|
||||||
|
|
||||||
|
const CollectionItem = ({
|
||||||
|
collection,
|
||||||
|
active,
|
||||||
|
collections,
|
||||||
|
}: {
|
||||||
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
active: string;
|
||||||
|
collections: CollectionIncludingMembersAndLinkCount[];
|
||||||
|
}) => {
|
||||||
|
const hasChildren = collections.some((e) => e.parentId === collection.id);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Check if the current collection or any of its subcollections is active
|
||||||
|
const isActiveOrParentOfActive = React.useMemo(() => {
|
||||||
|
const isActive = active === `/collections/${collection.id}`;
|
||||||
|
if (isActive) return true;
|
||||||
|
|
||||||
|
const checkIfParentOfActive = (parentId: number): boolean => {
|
||||||
|
return collections.some((e) => {
|
||||||
|
if (e.id === parseInt(active.split("/collections/")[1])) {
|
||||||
|
if (e.parentId === parentId) return true;
|
||||||
|
if (e.parentId) return checkIfParentOfActive(e.parentId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return checkIfParentOfActive(collection.id as number);
|
||||||
|
}, [active, collection.id, collections]);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(isActiveOrParentOfActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(isActiveOrParentOfActive);
|
||||||
|
}, [isActiveOrParentOfActive]);
|
||||||
|
|
||||||
|
return hasChildren ? (
|
||||||
|
<details open={isOpen}>
|
||||||
|
<summary
|
||||||
|
className={`${
|
||||||
|
active === `/collections/${collection.id}`
|
||||||
|
? "bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
} duration-100 rounded-md flex w-full items-center cursor-pointer mb-1 px-2`}
|
||||||
|
>
|
||||||
|
<Link href={`/collections/${collection.id}`} className="w-full">
|
||||||
|
<div
|
||||||
|
className={`py-1 cursor-pointer flex items-center gap-2 w-full h-8 capitalize`}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="bi-folder-fill text-2xl drop-shadow"
|
||||||
|
style={{ color: collection.color }}
|
||||||
|
></i>
|
||||||
|
<p className="truncate w-full">{collection.name}</p>
|
||||||
|
|
||||||
|
{collection.isPublic ? (
|
||||||
|
<i
|
||||||
|
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
||||||
|
title="This collection is being shared publicly."
|
||||||
|
></i>
|
||||||
|
) : undefined}
|
||||||
|
<div className="drop-shadow text-neutral text-xs">
|
||||||
|
{collection._count?.links}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="ml-3 pl-1 border-l border-neutral-content">
|
||||||
|
{collections
|
||||||
|
.filter((e) => e.parentId === collection.id)
|
||||||
|
.map((subCollection) => (
|
||||||
|
<CollectionItem
|
||||||
|
key={subCollection.id}
|
||||||
|
collection={subCollection}
|
||||||
|
active={active}
|
||||||
|
collections={collections}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
) : (
|
||||||
|
<Link href={`/collections/${collection.id}`} className="w-full">
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
active === `/collections/${collection.id}`
|
||||||
|
? "bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize mb-1`}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="bi-folder-fill text-2xl drop-shadow"
|
||||||
|
style={{ color: collection.color }}
|
||||||
|
></i>
|
||||||
|
<p className="truncate w-full">{collection.name}</p>
|
||||||
|
|
||||||
|
{collection.isPublic ? (
|
||||||
|
<i
|
||||||
|
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
||||||
|
title="This collection is being shared publicly."
|
||||||
|
></i>
|
||||||
|
) : undefined}
|
||||||
|
<div className="drop-shadow text-neutral text-xs">
|
||||||
|
{collection._count?.links}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
|
@ -5,17 +5,20 @@ import toast from "react-hot-toast";
|
||||||
import { HexColorPicker } from "react-colorful";
|
import { HexColorPicker } from "react-colorful";
|
||||||
import { Collection } from "@prisma/client";
|
import { Collection } from "@prisma/client";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
|
parent?: CollectionIncludingMembersAndLinkCount;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NewCollectionModal({ onClose }: Props) {
|
export default function NewCollectionModal({ onClose, parent }: Props) {
|
||||||
const initial = {
|
const initial = {
|
||||||
|
parentId: parent?.id,
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
color: "#0ea5e9",
|
color: "#0ea5e9",
|
||||||
};
|
} as Partial<Collection>;
|
||||||
|
|
||||||
const [collection, setCollection] = useState<Partial<Collection>>(initial);
|
const [collection, setCollection] = useState<Partial<Collection>>(initial);
|
||||||
|
|
||||||
|
@ -47,7 +50,14 @@ export default function NewCollectionModal({ onClose }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal toggleModal={onClose}>
|
<Modal toggleModal={onClose}>
|
||||||
<p className="text-xl font-thin">Create a New Collection</p>
|
{parent?.id ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xl font-thin">New Sub-Collection</p>
|
||||||
|
<p className="capitalize text-sm">For {parent.name}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xl font-thin">Create a New Collection</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="divider mb-3 mt-1"></div>
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
|
|
@ -179,7 +179,7 @@ export default function NewLinkModal({ onClose }: Props) {
|
||||||
setLink({ ...link, description: e.target.value })
|
setLink({ ...link, description: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="Will be auto generated if nothing is provided."
|
placeholder="Will be auto generated if nothing is provided."
|
||||||
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
|
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -74,7 +74,7 @@ export default function NewTokenModal({ onClose }: Props) {
|
||||||
|
|
||||||
<div className="divider mb-3 mt-1"></div>
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex sm:flex-row flex-col gap-2 items-center">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="mb-2">Name</p>
|
<p className="mb-2">Name</p>
|
||||||
|
|
||||||
|
@ -86,15 +86,15 @@ export default function NewTokenModal({ onClose }: Props) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="w-full sm:w-fit">
|
||||||
<p className="mb-2">Expires in</p>
|
<p className="mb-2">Expires in</p>
|
||||||
|
|
||||||
<div className="dropdown dropdown-bottom dropdown-end">
|
<div className="dropdown dropdown-bottom dropdown-end w-full">
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
onMouseDown={dropdownTriggerer}
|
onMouseDown={dropdownTriggerer}
|
||||||
className="btn btn-outline w-36 flex items-center btn-sm h-10"
|
className="btn btn-outline w-full sm:w-36 flex items-center btn-sm h-10"
|
||||||
>
|
>
|
||||||
{token.expires === TokenExpiry.sevenDays && "7 Days"}
|
{token.expires === TokenExpiry.sevenDays && "7 Days"}
|
||||||
{token.expires === TokenExpiry.oneMonth && "30 Days"}
|
{token.expires === TokenExpiry.oneMonth && "30 Days"}
|
||||||
|
@ -102,7 +102,7 @@ export default function NewTokenModal({ onClose }: Props) {
|
||||||
{token.expires === TokenExpiry.threeMonths && "90 Days"}
|
{token.expires === TokenExpiry.threeMonths && "90 Days"}
|
||||||
{token.expires === TokenExpiry.never && "No Expiration"}
|
{token.expires === TokenExpiry.never && "No Expiration"}
|
||||||
</div>
|
</div>
|
||||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1">
|
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-full sm:w-52 mt-1">
|
||||||
<li>
|
<li>
|
||||||
<label
|
<label
|
||||||
className="label cursor-pointer flex justify-start"
|
className="label cursor-pointer flex justify-start"
|
||||||
|
@ -212,7 +212,7 @@ export default function NewTokenModal({ onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-5">
|
<div className="flex justify-end items-center mt-5">
|
||||||
<button
|
<button
|
||||||
className="btn btn-accent dark:border-violet-400 text-white"
|
className="btn btn-accent dark:border-violet-400 text-white"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
|
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
|
||||||
|
import CollectionSelection from "@/components/CollectionSelection";
|
||||||
|
|
||||||
export default function Sidebar({ className }: { className?: string }) {
|
export default function Sidebar({ className }: { className?: string }) {
|
||||||
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
||||||
|
@ -97,48 +98,8 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||||
leaveFrom="transform opacity-100 translate-y-0"
|
leaveFrom="transform opacity-100 translate-y-0"
|
||||||
leaveTo="transform opacity-0 -translate-y-3"
|
leaveTo="transform opacity-0 -translate-y-3"
|
||||||
>
|
>
|
||||||
<Disclosure.Panel className="flex flex-col gap-1">
|
<Disclosure.Panel>
|
||||||
{collections[0] ? (
|
<CollectionSelection links={true} />
|
||||||
collections
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.map((e, i) => {
|
|
||||||
return (
|
|
||||||
<Link key={i} href={`/collections/${e.id}`}>
|
|
||||||
<div
|
|
||||||
className={`${
|
|
||||||
active === `/collections/${e.id}`
|
|
||||||
? "bg-primary/20"
|
|
||||||
: "hover:bg-neutral/20"
|
|
||||||
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="bi-folder-fill text-2xl drop-shadow"
|
|
||||||
style={{ color: e.color }}
|
|
||||||
></i>
|
|
||||||
<p className="truncate w-full">{e.name}</p>
|
|
||||||
|
|
||||||
{e.isPublic ? (
|
|
||||||
<i
|
|
||||||
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
|
||||||
title="This collection is being shared publicly."
|
|
||||||
></i>
|
|
||||||
) : undefined}
|
|
||||||
<div className="drop-shadow text-neutral text-xs">
|
|
||||||
{e._count?.links}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
|
||||||
>
|
|
||||||
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
|
|
||||||
You Have No Collections...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Disclosure.Panel>
|
</Disclosure.Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
|
|
@ -37,6 +37,8 @@ export default async function deleteCollection(
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedCollection = await prisma.$transaction(async () => {
|
const deletedCollection = await prisma.$transaction(async () => {
|
||||||
|
await deleteSubCollections(collectionId);
|
||||||
|
|
||||||
await prisma.usersAndCollections.deleteMany({
|
await prisma.usersAndCollections.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
collection: {
|
collection: {
|
||||||
|
@ -53,7 +55,7 @@ export default async function deleteCollection(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
removeFolder({ filePath: `archives/${collectionId}` });
|
await removeFolder({ filePath: `archives/${collectionId}` });
|
||||||
|
|
||||||
return await prisma.collection.delete({
|
return await prisma.collection.delete({
|
||||||
where: {
|
where: {
|
||||||
|
@ -64,3 +66,35 @@ export default async function deleteCollection(
|
||||||
|
|
||||||
return { response: deletedCollection, status: 200 };
|
return { response: deletedCollection, status: 200 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteSubCollections(collectionId: number) {
|
||||||
|
const subCollections = await prisma.collection.findMany({
|
||||||
|
where: { parentId: collectionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const subCollection of subCollections) {
|
||||||
|
await deleteSubCollections(subCollection.id);
|
||||||
|
|
||||||
|
await prisma.usersAndCollections.deleteMany({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
id: subCollection.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.link.deleteMany({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
id: subCollection.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.collection.delete({
|
||||||
|
where: { id: subCollection.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await removeFolder({ filePath: `archives/${subCollection.id}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { prisma } from "@/lib/api/db";
|
||||||
|
|
||||||
|
export default async function getCollectionById(
|
||||||
|
userId: number,
|
||||||
|
collectionId: number
|
||||||
|
) {
|
||||||
|
const collections = await prisma.collection.findFirst({
|
||||||
|
where: {
|
||||||
|
id: collectionId,
|
||||||
|
OR: [
|
||||||
|
{ ownerId: userId },
|
||||||
|
{ members: { some: { user: { id: userId } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { links: true },
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
name: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { response: collections, status: 200 };
|
||||||
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import { Collection, UsersAndCollections } from "@prisma/client";
|
|
||||||
|
|
||||||
export default async function updateCollection(
|
export default async function updateCollection(
|
||||||
userId: number,
|
userId: number,
|
||||||
|
@ -19,6 +18,26 @@ export default async function updateCollection(
|
||||||
if (!(collectionIsAccessible?.ownerId === userId))
|
if (!(collectionIsAccessible?.ownerId === userId))
|
||||||
return { response: "Collection is not accessible.", status: 401 };
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
|
if (data.parentId) {
|
||||||
|
const findParentCollection = await prisma.collection.findUnique({
|
||||||
|
where: {
|
||||||
|
id: data.parentId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
ownerId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
findParentCollection?.ownerId !== userId ||
|
||||||
|
typeof data.parentId !== "number"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
response: "You are not authorized to create a sub-collection here.",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const updatedCollection = await prisma.$transaction(async () => {
|
const updatedCollection = await prisma.$transaction(async () => {
|
||||||
await prisma.usersAndCollections.deleteMany({
|
await prisma.usersAndCollections.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
@ -38,6 +57,11 @@ export default async function updateCollection(
|
||||||
description: data.description,
|
description: data.description,
|
||||||
color: data.color,
|
color: data.color,
|
||||||
isPublic: data.isPublic,
|
isPublic: data.isPublic,
|
||||||
|
parent: {
|
||||||
|
connect: {
|
||||||
|
id: data.parentId || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
members: {
|
members: {
|
||||||
create: data.members.map((e) => ({
|
create: data.members.map((e) => ({
|
||||||
user: { connect: { id: e.user.id || e.userId } },
|
user: { connect: { id: e.user.id || e.userId } },
|
||||||
|
|
|
@ -12,6 +12,26 @@ export default async function postCollection(
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (collection.parentId) {
|
||||||
|
const findParentCollection = await prisma.collection.findUnique({
|
||||||
|
where: {
|
||||||
|
id: collection.parentId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
ownerId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
findParentCollection?.ownerId !== userId ||
|
||||||
|
typeof collection.parentId !== "number"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
response: "You are not authorized to create a sub-collection here.",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const findCollection = await prisma.user.findUnique({
|
const findCollection = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
|
@ -28,7 +48,10 @@ export default async function postCollection(
|
||||||
const checkIfCollectionExists = findCollection?.collections[0];
|
const checkIfCollectionExists = findCollection?.collections[0];
|
||||||
|
|
||||||
if (checkIfCollectionExists)
|
if (checkIfCollectionExists)
|
||||||
return { response: "Collection already exists.", status: 400 };
|
return {
|
||||||
|
response: "Oops! There's already a Collection with that name.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
const newCollection = await prisma.collection.create({
|
const newCollection = await prisma.collection.create({
|
||||||
data: {
|
data: {
|
||||||
|
@ -40,6 +63,13 @@ export default async function postCollection(
|
||||||
name: collection.name.trim(),
|
name: collection.name.trim(),
|
||||||
description: collection.description,
|
description: collection.description,
|
||||||
color: collection.color,
|
color: collection.color,
|
||||||
|
parent: collection.parentId
|
||||||
|
? {
|
||||||
|
connect: {
|
||||||
|
id: collection.parentId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import getCollectionById from "@/lib/api/controllers/collections/collectionId/getCollectionById";
|
||||||
import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById";
|
import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById";
|
||||||
import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById";
|
import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById";
|
||||||
import verifyUser from "@/lib/api/verifyUser";
|
import verifyUser from "@/lib/api/verifyUser";
|
||||||
|
@ -10,18 +11,18 @@ export default async function collections(
|
||||||
const user = await verifyUser({ req, res });
|
const user = await verifyUser({ req, res });
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
if (req.method === "PUT") {
|
const collectionId = Number(req.query.id);
|
||||||
const updated = await updateCollectionById(
|
|
||||||
user.id,
|
if (req.method === "GET") {
|
||||||
Number(req.query.id) as number,
|
const collections = await getCollectionById(user.id, collectionId);
|
||||||
req.body
|
return res
|
||||||
);
|
.status(collections.status)
|
||||||
|
.json({ response: collections.response });
|
||||||
|
} else if (req.method === "PUT") {
|
||||||
|
const updated = await updateCollectionById(user.id, collectionId, req.body);
|
||||||
return res.status(updated.status).json({ response: updated.response });
|
return res.status(updated.status).json({ response: updated.response });
|
||||||
} else if (req.method === "DELETE") {
|
} else if (req.method === "DELETE") {
|
||||||
const deleted = await deleteCollectionById(
|
const deleted = await deleteCollectionById(user.id, collectionId);
|
||||||
user.id,
|
|
||||||
Number(req.query.id) as number
|
|
||||||
);
|
|
||||||
return res.status(deleted.status).json({ response: deleted.response });
|
return res.status(deleted.status).json({ response: deleted.response });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,8 @@ 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";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { settings } = useLocalSettingsStore();
|
const { settings } = useLocalSettingsStore();
|
||||||
|
@ -82,6 +84,7 @@ export default function Index() {
|
||||||
}, [activeCollection]);
|
}, [activeCollection]);
|
||||||
|
|
||||||
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
||||||
|
const [newCollectionModal, setNewCollectionModal] = useState(false);
|
||||||
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
|
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
|
||||||
|
@ -160,6 +163,20 @@ export default function Index() {
|
||||||
: "View Team"}
|
: "View Team"}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{permissions === true ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setNewCollectionModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Sub-Collection
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : undefined}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -228,6 +245,31 @@ export default function Index() {
|
||||||
<p>{activeCollection?.description}</p>
|
<p>{activeCollection?.description}</p>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
|
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
|
||||||
|
<fieldset className="border rounded-md p-2 border-neutral-content">
|
||||||
|
<legend className="text-sm ml-2">Sub-Collections</legend>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{collections
|
||||||
|
.filter((e) => e.parentId === activeCollection?.id)
|
||||||
|
.map((e, i) => {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
className="flex gap-1 items-center btn btn-ghost btn-sm"
|
||||||
|
href={`/collections/${e.id}`}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="bi-folder-fill text-2xl drop-shadow"
|
||||||
|
style={{ color: e.color }}
|
||||||
|
></i>
|
||||||
|
<p className="text-xs">{e.name}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
) : undefined} */}
|
||||||
|
|
||||||
<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-end gap-5">
|
||||||
|
@ -262,6 +304,12 @@ export default function Index() {
|
||||||
activeCollection={activeCollection}
|
activeCollection={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
{newCollectionModal ? (
|
||||||
|
<NewCollectionModal
|
||||||
|
onClose={() => setNewCollectionModal(false)}
|
||||||
|
parent={activeCollection}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
{deleteCollectionModal ? (
|
{deleteCollectionModal ? (
|
||||||
<DeleteCollectionModal
|
<DeleteCollectionModal
|
||||||
onClose={() => setDeleteCollectionModal(false)}
|
onClose={() => setDeleteCollectionModal(false)}
|
||||||
|
|
|
@ -39,7 +39,7 @@ export default function Collections() {
|
||||||
|
|
||||||
<div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
<div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||||
{sortedCollections
|
{sortedCollections
|
||||||
.filter((e) => e.ownerId === data?.user.id)
|
.filter((e) => e.ownerId === data?.user.id && e.parentId === null)
|
||||||
.map((e, i) => {
|
.map((e, i) => {
|
||||||
return <CollectionCard key={i} collection={e} />;
|
return <CollectionCard key={i} collection={e} />;
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -38,7 +38,7 @@ export default function AccessTokens() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`btn btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2`}
|
className={`btn ml-auto btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNewTokenModal(true);
|
setNewTokenModal(true);
|
||||||
}}
|
}}
|
||||||
|
@ -48,7 +48,7 @@ export default function AccessTokens() {
|
||||||
|
|
||||||
{tokens.length > 0 ? (
|
{tokens.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="divider"></div>
|
<div className="divider my-0"></div>
|
||||||
|
|
||||||
<table className="table">
|
<table className="table">
|
||||||
{/* head */}
|
{/* head */}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Collection" ADD COLUMN "parentId" INTEGER;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Collection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -69,17 +69,20 @@ model VerificationToken {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Collection {
|
model Collection {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
description String @default("")
|
description String @default("")
|
||||||
color String @default("#0ea5e9")
|
color String @default("#0ea5e9")
|
||||||
isPublic Boolean @default(false)
|
parentId Int?
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
parent Collection? @relation("SubCollections", fields: [parentId], references: [id])
|
||||||
ownerId Int
|
subCollections Collection[] @relation("SubCollections")
|
||||||
members UsersAndCollections[]
|
isPublic Boolean @default(false)
|
||||||
links Link[]
|
owner User @relation(fields: [ownerId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
ownerId Int
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
members UsersAndCollections[]
|
||||||
|
links Link[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
@@unique([name, ownerId])
|
@@unique([name, ownerId])
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import 'dotenv/config';
|
import "dotenv/config";
|
||||||
import { Collection, Link, User } from "@prisma/client";
|
import { Collection, Link, User } from "@prisma/client";
|
||||||
import { prisma } from "../lib/api/db";
|
import { prisma } from "../lib/api/db";
|
||||||
import archiveHandler from "../lib/api/archiveHandler";
|
import archiveHandler from "../lib/api/archiveHandler";
|
||||||
|
|
|
@ -78,7 +78,11 @@ const useCollectionStore = create<CollectionStore>()((set) => ({
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
collections: state.collections.filter((e) => e.id !== collectionId),
|
collections: state.collections.filter(
|
||||||
|
(collection) =>
|
||||||
|
collection.id !== collectionId &&
|
||||||
|
collection.parentId !== collectionId
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
useTagStore.getState().setTags();
|
useTagStore.getState().setTags();
|
||||||
}
|
}
|
||||||
|
|
Ŝarĝante…
Reference in New Issue