managed how collections are viewed by members

This commit is contained in:
Daniel 2023-06-22 18:05:02 +03:30
parent 51c5615fea
commit 3abea1d1b7
13 changed files with 432 additions and 288 deletions

View File

@ -7,6 +7,7 @@ import { useState } from "react";
import ProfilePhoto from "./ProfilePhoto";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import useModalStore from "@/store/modals";
import usePermissions from "@/hooks/usePermissions";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
@ -27,6 +28,8 @@ export default function CollectionCard({ collection, className }: Props) {
const [expandDropdown, setExpandDropdown] = useState(false);
const permissions = usePermissions(collection.id as number);
return (
<div
className={`bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% to-white to-100% self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none group relative ${className}`}
@ -58,7 +61,7 @@ export default function CollectionCard({ collection, className }: Props) {
<ProfilePhoto
key={i}
src={`/api/avatar/${e.userId}`}
className="-mr-3"
className="-mr-3 border-[3px]"
/>
);
})
@ -72,7 +75,7 @@ export default function CollectionCard({ collection, className }: Props) {
<div className="text-right w-40">
<div className="text-sky-500 font-bold text-sm flex justify-end gap-1 items-center">
<FontAwesomeIcon icon={faLink} className="w-5 h-5 text-sky-600" />
{collection._count.links}
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-600">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
@ -91,6 +94,7 @@ export default function CollectionCard({ collection, className }: Props) {
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
});
setExpandDropdown(false);
@ -103,6 +107,7 @@ export default function CollectionCard({ collection, className }: Props) {
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
defaultIndex: 1,
});
@ -116,6 +121,7 @@ export default function CollectionCard({ collection, className }: Props) {
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
defaultIndex: 2,
});

View File

@ -1,7 +1,6 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
Member,
} from "@/types/global";
import { faFolder, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -61,7 +60,7 @@ export default function LinkCard({ link, count, className }: Props) {
return (
<div
className={`bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow hover:shadow-none cursor-pointer duration-100 p-5 rounded-2xl relative group ${className}`}
className={`bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow hover:shadow-none cursor-pointer duration-100 rounded-2xl relative group ${className}`}
>
{permissions && (
<div
@ -87,7 +86,7 @@ export default function LinkCard({ link, count, className }: Props) {
active: link,
});
}}
className="flex items-start gap-5 sm:gap-10 h-full w-full"
className="flex items-start gap-5 sm:gap-10 h-full w-full p-5"
>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}

View File

@ -1,9 +1,13 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import {
faRightFromBracket,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
type Props = {
toggleDeleteCollectionModal: Function;
@ -21,24 +25,28 @@ export default function DeleteCollection({
const router = useRouter();
const submit = async () => {
if (!collection.id || collection.name !== inputField) return null;
if (permissions === true) if (collection.name !== inputField) return null;
const response = await removeCollection(collection.id);
const response = await removeCollection(collection.id as number);
if (response) {
toggleDeleteCollectionModal();
router.push("/collections");
}
};
const permissions = usePermissions(collection.id as number);
return (
<div className="flex flex-col gap-3 justify-between sm:w-[35rem] w-80">
{permissions === true ? (
<>
<p className="text-red-500 font-bold text-center">Warning!</p>
<div className="max-h-[20rem] overflow-y-auto">
<div className="text-gray-500">
<p>
Please note that deleting the collection will permanently remove all
its contents, including the following:
Please note that deleting the collection will permanently remove
all its contents, including the following:
</p>
<div className="p-3">
<li className="list-inside">
@ -49,18 +57,18 @@ export default function DeleteCollection({
Tags: All tags associated with the collection will be removed.
</li>
<li className="list-inside">
Screenshots/PDFs: Any screenshots or PDFs attached to links within
this collection will be permanently deleted.
Screenshots/PDFs: Any screenshots or PDFs attached to links
within this collection will be permanently deleted.
</li>
<li className="list-inside">
Members: Any members who have been granted access to the
collection will lose their permissions and no longer be able to
view or interact with the content.
collection will lose their permissions and no longer be able
to view or interact with the content.
</li>
</div>
<p>
Please double-check that you have backed up any essential data and
have informed the relevant members about this action.
Please double-check that you have backed up any essential data
and have informed the relevant members about this action.
</p>
</div>
</div>
@ -81,17 +89,28 @@ export default function DeleteCollection({
className="w-72 sm:w-96 rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-500 duration-100"
/>
</div>
</>
) : (
<p className="text-gray-500">
Click the button below to leave the current collection:
</p>
)}
<div
className={`mx-auto mt-2 text-white flex items-center gap-2 py-2 px-5 rounded-md select-none font-bold duration-100 ${
inputField === collection.name
permissions === true
? inputField === collection.name
? "bg-red-500 hover:bg-red-400 cursor-pointer"
: "cursor-not-allowed bg-red-300"
: "bg-red-500 hover:bg-red-400 cursor-pointer"
}`}
onClick={submit}
>
<FontAwesomeIcon icon={faTrashCan} className="h-5" />
Delete Collection
<FontAwesomeIcon
icon={permissions === true ? faTrashCan : faRightFromBracket}
className="h-5"
/>
{permissions === true ? "Delete" : "Leave"} Collection
</div>
</div>
);

View File

@ -13,6 +13,7 @@ import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Checkbox from "../../Checkbox";
import SubmitButton from "@/components/SubmitButton";
import ProfilePhoto from "@/components/ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
type Props = {
toggleCollectionModal: Function;
@ -29,6 +30,8 @@ export default function TeamManagement({
collection,
method,
}: Props) {
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
@ -80,6 +83,8 @@ export default function TeamManagement({
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{permissions === true && (
<>
<p className="text-sm text-sky-500">Make Public</p>
<Checkbox
@ -93,6 +98,8 @@ export default function TeamManagement({
<p className="text-gray-500 text-sm">
This will let <b>Anyone</b> to view this collection.
</p>
</>
)}
{collection.isPublic ? (
<div>
@ -116,8 +123,10 @@ export default function TeamManagement({
</div>
) : null}
<hr />
{permissions !== true && collection.isPublic && <hr />}
{permissions === true && (
<>
<p className="text-sm text-sky-500">Member Management</p>
<div className="flex items-center gap-2">
@ -157,13 +166,15 @@ export default function TeamManagement({
<FontAwesomeIcon icon={faUserPlus} className="w-5 h-5" />
</div>
</div>
</>
)}
{collection?.members[0]?.user && (
<>
<p className="text-center text-gray-500 text-xs sm:text-sm">
(All Members have <b>Read</b> access to this collection.)
</p>
<div className="max-h-[20rem] overflow-auto flex flex-col gap-3 rounded-md shadow-inner">
<div className="max-h-[20rem] overflow-auto flex flex-col gap-3 rounded-md">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
@ -172,6 +183,7 @@ export default function TeamManagement({
key={i}
className="relative border p-2 rounded-md border-sky-100 flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
>
{permissions === true && (
<FontAwesomeIcon
icon={faClose}
className="absolute right-2 top-2 text-gray-500 h-4 hover:text-red-500 duration-100 cursor-pointer"
@ -188,8 +200,12 @@ export default function TeamManagement({
});
}}
/>
)}
<div className="flex items-center gap-2">
<ProfilePhoto src={`/api/avatar/${e.userId}`} />
<ProfilePhoto
src={`/api/avatar/${e.userId}`}
className="border-[3px]"
/>
<div>
<p className="text-sm font-bold text-sky-500">
{e.user.name}
@ -197,24 +213,45 @@ export default function TeamManagement({
<p className="text-sky-900">{e.user.email}</p>
</div>
</div>
<div className="flex sm:block items-center gap-5">
<div className="flex sm:block items-center gap-5 min-w-[10rem]">
<div>
<p className="font-bold text-sm text-sky-500">
<p
className={`font-bold text-sm text-sky-500 ${
permissions === true ? "" : "mb-2"
}`}
>
Permissions
</p>
{permissions === true && (
<p className="text-xs text-gray-500 mb-2">
(Click to toggle.)
</p>
)}
</div>
{permissions !== true &&
!e.canCreate &&
!e.canUpdate &&
!e.canDelete ? (
<p className="text-sm text-gray-500">
Has no permissions.
</p>
) : (
<div>
<label className="cursor-pointer mr-1">
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canCreate"
className="peer sr-only"
checked={e.canCreate}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (member.user.email === e.user.email) {
@ -230,20 +267,34 @@ export default function TeamManagement({
...collection,
members: updatedMembers,
});
}
}}
/>
<span className="text-sky-900 peer-checked:bg-sky-500 text-sm hover:bg-slate-200 duration-75 peer-checked:text-white rounded p-1 select-none">
<span
className={`text-sky-900 peer-checked:bg-sky-500 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
: ""
} peer-checked:text-white rounded p-1 select-none`}
>
Create
</span>
</label>
<label className="cursor-pointer mr-1">
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canUpdate"
className="peer sr-only"
checked={e.canUpdate}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (member.user.email === e.user.email) {
@ -259,20 +310,34 @@ export default function TeamManagement({
...collection,
members: updatedMembers,
});
}
}}
/>
<span className="text-sky-900 peer-checked:bg-sky-500 text-sm hover:bg-slate-200 duration-75 peer-checked:text-white rounded p-1 select-none">
<span
className={`text-sky-900 peer-checked:bg-sky-500 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
: ""
} peer-checked:text-white rounded p-1 select-none`}
>
Update
</span>
</label>
<label className="cursor-pointer mr-1">
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canDelete"
className="peer sr-only"
checked={e.canDelete}
onChange={() => {
if (permissions === true) {
const updatedMembers = collection.members.map(
(member) => {
if (member.user.email === e.user.email) {
@ -288,13 +353,21 @@ export default function TeamManagement({
...collection,
members: updatedMembers,
});
}
}}
/>
<span className="text-sky-900 peer-checked:bg-sky-500 text-sm hover:bg-slate-200 duration-75 peer-checked:text-white rounded p-1 select-none">
<span
className={`text-sky-900 peer-checked:bg-sky-500 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
: ""
} peer-checked:text-white rounded p-1 select-none`}
>
Delete
</span>
</label>
</div>
)}
</div>
</div>
);
@ -303,12 +376,14 @@ export default function TeamManagement({
</>
)}
{permissions === true && (
<SubmitButton
onClick={submit}
label={method === "CREATE" ? "Add" : "Save"}
icon={method === "CREATE" ? faPlus : faPenToSquare}
className="mx-auto mt-2"
/>
)}
</div>
);
}

View File

@ -10,6 +10,7 @@ type Props =
toggleCollectionModal: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
method: "UPDATE";
isOwner: boolean;
className?: string;
defaultIndex?: number;
}
@ -17,6 +18,7 @@ type Props =
toggleCollectionModal: Function;
activeCollection?: CollectionIncludingMembersAndLinkCount;
method: "CREATE";
isOwner: boolean;
className?: string;
defaultIndex?: number;
};
@ -25,6 +27,7 @@ export default function CollectionModal({
className,
defaultIndex,
toggleCollectionModal,
isOwner,
activeCollection,
method,
}: Props) {
@ -48,6 +51,7 @@ export default function CollectionModal({
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-sky-600">
{method === "UPDATE" && (
<>
{isOwner && (
<Tab
className={({ selected }) =>
selected
@ -57,6 +61,7 @@ export default function CollectionModal({
>
Collection Info
</Tab>
)}
<Tab
className={({ selected }) =>
selected
@ -64,7 +69,7 @@ export default function CollectionModal({
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Share & Collaborate
{isOwner ? "Share & Collaborate" : "View Team"}
</Tab>
<Tab
className={({ selected }) =>
@ -73,12 +78,13 @@ export default function CollectionModal({
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Delete Collection
{isOwner ? "Delete Collection" : "Leave Collection"}
</Tab>
</>
)}
</Tab.List>
<Tab.Panels>
{(isOwner || method === "CREATE") && (
<Tab.Panel>
<CollectionInfo
toggleCollectionModal={toggleCollectionModal}
@ -87,6 +93,7 @@ export default function CollectionModal({
method={method}
/>
</Tab.Panel>
)}
{method === "UPDATE" && (
<>

View File

@ -77,10 +77,10 @@ export default function ProfileSettings({
<div className="grid sm:grid-cols-2 gap-3 auto-rows-auto">
<div className="sm:row-span-2 sm:justify-self-center mx-auto mb-3">
<p className="text-sm text-sky-500 mb-2 text-center">Profile Photo</p>
<div className="w-28 h-28 flex items-center justify-center border border-sky-100 rounded-full relative">
<div className="w-28 h-28 flex items-center justify-center rounded-full relative">
<ProfilePhoto
src={user.profilePic}
className="h-auto aspect-square w-28 border-[1px]"
className="h-auto w-28"
status={handleProfileStatus}
/>
{profileStatus && (

View File

@ -40,6 +40,7 @@ export default function ModalManagement() {
<CollectionModal
toggleCollectionModal={toggleModal}
method={modal.method}
isOwner={modal.isOwner}
defaultIndex={modal.defaultIndex}
activeCollection={
modal.active as CollectionIncludingMembersAndLinkCount

View File

@ -69,7 +69,7 @@ export default function Navbar() {
>
<ProfilePhoto
src={account.profilePic}
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100"
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
/>
<p
id="profile-dropdown"

View File

@ -33,7 +33,7 @@ export default function ProfilePhoto({
return error || !src ? (
<div
className={`bg-sky-500 text-white h-10 w-10 aspect-square shadow rounded-full border-[3px] border-slate-200 flex items-center justify-center ${className}`}
className={`bg-sky-500 text-white h-10 w-10 aspect-square shadow rounded-full border border-slate-200 flex items-center justify-center ${className}`}
>
<FontAwesomeIcon icon={faUser} className="w-1/2 h-1/2 aspect-square" />
</div>
@ -43,7 +43,7 @@ export default function ProfilePhoto({
src={src}
height={112}
width={112}
className={`h-10 w-10 shadow rounded-full aspect-square border-[3px] border-slate-200 ${className}`}
className={`h-10 w-10 shadow rounded-full aspect-square border border-slate-200 ${className}`}
/>
);
}

View File

@ -1,7 +1,7 @@
import useAccountStore from "@/store/account";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import React, { useEffect, useState } from "react";
import { Member } from "@/types/global";
import { useEffect, useState } from "react";
export default function usePermissions(collectionId: number) {
const { collections } = useCollectionStore();
@ -26,7 +26,7 @@ export default function usePermissions(collectionId: number) {
setPermissions(account.id === collection.ownerId || getPermission);
}
}, [collections]);
}, [account, collections, collectionId]);
return permissions;
}

View File

@ -1,24 +1,45 @@
import { prisma } from "@/lib/api/db";
import getPermission from "@/lib/api/getPermission";
import { UsersAndCollections } from "@prisma/client";
import fs from "fs";
export default async function deleteCollection(
collection: { id: number },
userId: number
) {
if (!collection.id)
const collectionId = collection.id;
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const collectionIsAccessible = await getPermission(userId, collection.id);
const collectionIsAccessible = await getPermission(userId, collectionId);
if (!(collectionIsAccessible?.ownerId === userId))
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
);
if (collectionIsAccessible?.ownerId !== userId && memberHasAccess) {
// Remove relation/Leave collection
const deletedUsersAndCollectionsRelation =
await prisma.usersAndCollections.delete({
where: {
userId_collectionId: {
userId: userId,
collectionId: collectionId,
},
},
});
return { response: deletedUsersAndCollectionsRelation, status: 200 };
} else if (collectionIsAccessible?.ownerId !== userId) {
return { response: "Collection is not accessible.", status: 401 };
}
const deletedCollection = await prisma.$transaction(async () => {
await prisma.usersAndCollections.deleteMany({
where: {
collection: {
id: collection.id,
id: collectionId,
},
},
});
@ -26,13 +47,13 @@ export default async function deleteCollection(
await prisma.link.deleteMany({
where: {
collection: {
id: collection.id,
id: collectionId,
},
},
});
try {
fs.rmdirSync(`data/archives/${collection.id}`, { recursive: true });
fs.rmdirSync(`data/archives/${collectionId}`, { recursive: true });
} catch (error) {
console.log(
"Collection's archive directory wasn't deleted most likely because it didn't exist..."
@ -41,7 +62,7 @@ export default async function deleteCollection(
return await prisma.collection.delete({
where: {
id: collection.id,
id: collectionId,
},
});
});

View File

@ -17,6 +17,7 @@ import ProfilePhoto from "@/components/ProfilePhoto";
import SortDropdown from "@/components/SortDropdown";
import useModalStore from "@/store/modals";
import useLinks from "@/hooks/useLinks";
import usePermissions from "@/hooks/usePermissions";
export default function Index() {
const { setModal } = useModalStore();
@ -35,6 +36,8 @@ export default function Index() {
const [activeCollection, setActiveCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const permissions = usePermissions(activeCollection?.id as number);
useLinks({ collectionId: Number(router.query.id), sort: sortBy });
useEffect(() => {
@ -71,13 +74,13 @@ export default function Index() {
>
<div
onClick={() =>
activeCollection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: activeCollection,
defaultIndex: 1,
defaultIndex: permissions === true ? 1 : 0,
})
}
className="flex justify-center sm:justify-end items-center w-fit mx-auto sm:mr-0 sm:ml-auto group cursor-pointer"
@ -87,10 +90,7 @@ export default function Index() {
activeCollection.members[0] && "mr-1"
}`}
>
{activeCollection.ownerId === data?.user.id
? "Manage"
: "View"}{" "}
Team
{permissions === true ? "Manage" : "View"} Team
</div>
{activeCollection?.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
@ -99,7 +99,7 @@ export default function Index() {
<ProfilePhoto
key={i}
src={`/api/avatar/${e.userId}`}
className="-mr-3 duration-100"
className="-mr-3 duration-100 border-[3px]"
/>
);
})
@ -155,7 +155,8 @@ export default function Index() {
{expandDropdown ? (
<Dropdown
items={[
{
permissions === true || permissions?.canCreate
? {
name: "Add Link Here",
onClick: () => {
setModal({
@ -165,8 +166,10 @@ export default function Index() {
});
setExpandDropdown(false);
},
},
{
}
: undefined,
permissions === true
? {
name: "Edit Collection Info",
onClick: () => {
activeCollection &&
@ -174,35 +177,46 @@ export default function Index() {
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: activeCollection,
});
setExpandDropdown(false);
},
},
}
: undefined,
{
name: "Share/Collaborate",
name:
permissions === true
? "Share/Collaborate"
: "View Team",
onClick: () => {
activeCollection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: activeCollection,
defaultIndex: 1,
});
setExpandDropdown(false);
},
},
{
name: "Delete Collection",
name:
permissions === true
? "Delete Collection"
: "Leave Collection",
onClick: () => {
activeCollection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: activeCollection,
defaultIndex: 2,
defaultIndex: permissions === true ? 2 : 1,
});
setExpandDropdown(false);
},

View File

@ -30,6 +30,7 @@ type Modal =
modal: "COLLECTION";
state: boolean;
method: "UPDATE";
isOwner: boolean;
active: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}
@ -37,6 +38,7 @@ type Modal =
modal: "COLLECTION";
state: boolean;
method: "CREATE";
isOwner: boolean;
active?: CollectionIncludingMembersAndLinkCount;
defaultIndex?: number;
}