diff --git a/.env.sample b/.env.sample index d2a8d60..14f74df 100644 --- a/.env.sample +++ b/.env.sample @@ -20,6 +20,7 @@ MAX_LINKS_PER_USER= ARCHIVE_TAKE_COUNT= BROWSER_TIMEOUT= IGNORE_UNAUTHORIZED_CA= +IGNORE_HTTPS_ERRORS= # AWS S3 Settings SPACES_KEY= @@ -34,6 +35,15 @@ NEXT_PUBLIC_EMAIL_PROVIDER= EMAIL_FROM= EMAIL_SERVER= +# Proxy settings +PROXY= +PROXY_USERNAME= +PROXY_PASSWORD= +PROXY_BYPASS= + +# PDF archive settings +PDF_MARGIN_TOP= +PDF_MARGIN_BOTTOM= # # SSO Providers diff --git a/.gitignore b/.gitignore index 77956c8..906d2a6 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ prisma/dev.db /playwright/.cache/ # docker -pgdata \ No newline at end of file +pgdata +certificates \ No newline at end of file diff --git a/README.md b/README.md index b08ed87..cd3725a 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ We've forked the old version from the current repository into [this repo](https: - 📸 Auto capture a screenshot, PDF, and readable view of each webpage. - 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional) -- 📂 Organize links by collection, name, description and multiple tags. +- 📂 Organize links by collection, sub-collection, name, description and multiple tags. - 👥 Collaborate on gathering links in a collection. - 🎛️ Customize the permissions of each member. - 🌐 Share your collected links and preserved formats with the world. @@ -70,6 +70,10 @@ We've forked the old version from the current repository into [this repo](https: - 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension) - ⬇️ Import and export your bookmarks. - 🔐 SSO integration. (Enterprise and Self-hosted users only) +- 📦 Installable Progressive Web App (PWA). +- 🍎 iOS Shortcut to save links to Linkwarden. +- 🔑 API keys. +- ✅ Bulk actions. - ✨ And so many more features! ## Like what we're doing? Give us a Star ⭐ diff --git a/components/CollectionCard.tsx b/components/CollectionCard.tsx index d4c2571..1a6cc31 100644 --- a/components/CollectionCard.tsx +++ b/components/CollectionCard.tsx @@ -9,6 +9,7 @@ import useAccountStore from "@/store/account"; import EditCollectionModal from "./ModalContent/EditCollectionModal"; import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal"; import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal"; +import { dropdownTriggerer } from "@/lib/client/utils"; type Props = { collection: CollectionIncludingMembersAndLinkCount; @@ -70,6 +71,7 @@ export default function CollectionCard({ collection, className }: Props) {
@@ -170,7 +172,7 @@ export default function CollectionCard({ collection, className }: Props) {
{collection.isPublic ? ( ) : undefined} diff --git a/components/CollectionListing.tsx b/components/CollectionListing.tsx new file mode 100644 index 0000000..eb43386 --- /dev/null +++ b/components/CollectionListing.tsx @@ -0,0 +1,362 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Tree, { + mutateTree, + moveItemOnTree, + RenderItemParams, + TreeItem, + TreeData, + ItemId, + TreeSourcePosition, + TreeDestinationPosition, +} from "@atlaskit/tree"; +import useCollectionStore from "@/store/collections"; +import { Collection } from "@prisma/client"; +import Link from "next/link"; +import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; +import { useRouter } from "next/router"; +import useAccountStore from "@/store/account"; +import toast from "react-hot-toast"; + +interface ExtendedTreeItem extends TreeItem { + data: Collection; +} + +const CollectionListing = () => { + const { collections, updateCollection } = useCollectionStore(); + const { account, updateAccount } = useAccountStore(); + + const router = useRouter(); + const currentPath = router.asPath; + + const initialTree = useMemo(() => { + if (collections.length > 0) { + return buildTreeFromCollections( + collections, + router, + account.collectionOrder + ); + } + return undefined; + }, [collections, router]); + + const [tree, setTree] = useState(initialTree); + + useEffect(() => { + setTree(initialTree); + }, [initialTree]); + + useEffect(() => { + if (account.username) { + if (!account.collectionOrder || account.collectionOrder.length === 0) + updateAccount({ + ...account, + collectionOrder: collections + .filter( + (e) => + e.parentId === null || + !collections.find((i) => i.id === e.parentId) + ) // Filter out collections with non-null parentId + .map((e) => e.id as number), // Use "as number" to assert that e.id is a number + }); + else { + const newCollectionOrder: number[] = [ + ...(account.collectionOrder || []), + ]; + + // Start with collections that are in both account.collectionOrder and collections + const existingCollectionIds = collections.map((c) => c.id as number); + const filteredCollectionOrder = account.collectionOrder.filter((id) => + existingCollectionIds.includes(id) + ); + + // Add new collections that are not in account.collectionOrder and meet the specific conditions + collections.forEach((collection) => { + if ( + !filteredCollectionOrder.includes(collection.id as number) && + (!collection.parentId || collection.ownerId === account.id) + ) { + filteredCollectionOrder.push(collection.id as number); + } + }); + + // check if the newCollectionOrder is the same as the old one + if ( + JSON.stringify(newCollectionOrder) !== + JSON.stringify(account.collectionOrder) + ) { + updateAccount({ + ...account, + collectionOrder: newCollectionOrder, + }); + } + } + } + }, [collections]); + + const onExpand = (movedCollectionId: ItemId) => { + setTree((currentTree) => + mutateTree(currentTree!, movedCollectionId, { isExpanded: true }) + ); + }; + + const onCollapse = (movedCollectionId: ItemId) => { + setTree((currentTree) => + mutateTree(currentTree as TreeData, movedCollectionId, { + isExpanded: false, + }) + ); + }; + + const onDragEnd = async ( + source: TreeSourcePosition, + destination: TreeDestinationPosition | undefined + ) => { + if (!destination || !tree) { + return; + } + + if ( + source.index === destination.index && + source.parentId === destination.parentId + ) { + return; + } + + const movedCollectionId = Number( + tree.items[source.parentId].children[source.index] + ); + + const movedCollection = collections.find((c) => c.id === movedCollectionId); + + const destinationCollection = collections.find( + (c) => c.id === Number(destination.parentId) + ); + + if ( + (movedCollection?.ownerId !== account.id && + destination.parentId !== source.parentId) || + (destinationCollection?.ownerId !== account.id && + destination.parentId !== "root") + ) { + return toast.error( + "You can't make change to a collection you don't own." + ); + } + + setTree((currentTree) => moveItemOnTree(currentTree!, source, destination)); + + const updatedCollectionOrder = [...account.collectionOrder]; + + if (source.parentId !== destination.parentId) { + await updateCollection({ + ...movedCollection, + parentId: + destination.parentId && destination.parentId !== "root" + ? Number(destination.parentId) + : destination.parentId === "root" + ? "root" + : null, + } as any); + } + + if ( + destination.index !== undefined && + destination.parentId === source.parentId && + source.parentId === "root" + ) { + updatedCollectionOrder.includes(movedCollectionId) && + updatedCollectionOrder.splice(source.index, 1); + + updatedCollectionOrder.splice(destination.index, 0, movedCollectionId); + + await updateAccount({ + ...account, + collectionOrder: updatedCollectionOrder, + }); + } else if ( + destination.index !== undefined && + destination.parentId === "root" + ) { + updatedCollectionOrder.splice(destination.index, 0, movedCollectionId); + + await updateAccount({ + ...account, + collectionOrder: updatedCollectionOrder, + }); + } else if ( + source.parentId === "root" && + destination.parentId && + destination.parentId !== "root" + ) { + updatedCollectionOrder.splice(source.index, 1); + + await updateAccount({ + ...account, + collectionOrder: updatedCollectionOrder, + }); + } + }; + + if (!tree) { + return <>; + } else + return ( + renderItem({ ...itemProps }, currentPath)} + onExpand={onExpand} + onCollapse={onCollapse} + onDragEnd={onDragEnd} + isDragEnabled + isNestingEnabled + /> + ); +}; + +export default CollectionListing; + +const renderItem = ( + { item, onExpand, onCollapse, provided }: RenderItemParams, + currentPath: string +) => { + const collection = item.data; + + return ( +
+
+ {Icon(item as ExtendedTreeItem, onExpand, onCollapse)} + + +
+ +

{collection.name}

+ + {collection.isPublic ? ( + + ) : undefined} +
+ {collection._count?.links} +
+
+ +
+
+ ); +}; + +const Icon = ( + item: ExtendedTreeItem, + onExpand: (id: ItemId) => void, + onCollapse: (id: ItemId) => void +) => { + if (item.children && item.children.length > 0) { + return item.isExpanded ? ( + + ) : ( + + ); + } + // return ; + return
; +}; + +const buildTreeFromCollections = ( + collections: CollectionIncludingMembersAndLinkCount[], + router: ReturnType, + order?: number[] +): TreeData => { + if (order) { + collections.sort((a: any, b: any) => { + return order.indexOf(a.id) - order.indexOf(b.id); + }); + } + + const items: { [key: string]: ExtendedTreeItem } = collections.reduce( + (acc: any, collection) => { + acc[collection.id as number] = { + id: collection.id, + children: [], + hasChildren: false, + isExpanded: false, + data: { + id: collection.id, + parentId: collection.parentId, + name: collection.name, + description: collection.description, + color: collection.color, + isPublic: collection.isPublic, + ownerId: collection.ownerId, + createdAt: collection.createdAt, + updatedAt: collection.updatedAt, + _count: { + links: collection._count?.links, + }, + }, + }; + return acc; + }, + {} + ); + + const activeCollectionId = Number(router.asPath.split("/collections/")[1]); + + if (activeCollectionId) { + for (const item in items) { + const collection = items[item]; + if (Number(item) === activeCollectionId && collection.data.parentId) { + // get all the parents of the active collection recursively until root and set isExpanded to true + let parentId = collection.data.parentId || null; + while (parentId && items[parentId]) { + items[parentId].isExpanded = true; + parentId = items[parentId].data.parentId; + } + } + } + } + + collections.forEach((collection) => { + const parentId = collection.parentId; + if (parentId && items[parentId] && collection.id) { + items[parentId].children.push(collection.id); + items[parentId].hasChildren = true; + } + }); + + const rootId = "root"; + items[rootId] = { + id: rootId, + children: (collections + .filter( + (c) => + c.parentId === null || !collections.find((i) => i.id === c.parentId) + ) + .map((c) => c.id) || "") as unknown as string[], + hasChildren: true, + isExpanded: true, + data: { name: "Root" } as Collection, + }; + + return { rootId, items }; +}; diff --git a/components/FilterSearchDropdown.tsx b/components/FilterSearchDropdown.tsx index 9ab2946..57a0ea4 100644 --- a/components/FilterSearchDropdown.tsx +++ b/components/FilterSearchDropdown.tsx @@ -1,3 +1,4 @@ +import { dropdownTriggerer } from "@/lib/client/utils"; import React from "react"; type Props = { @@ -20,6 +21,7 @@ export default function FilterSearchDropdown({
diff --git a/components/InputSelect/CollectionSelection.tsx b/components/InputSelect/CollectionSelection.tsx index 71dc075..99b999e 100644 --- a/components/InputSelect/CollectionSelection.tsx +++ b/components/InputSelect/CollectionSelection.tsx @@ -4,18 +4,26 @@ import { useEffect, useState } from "react"; import { styles } from "./styles"; import { Options } from "./types"; import CreatableSelect from "react-select/creatable"; +import Select from "react-select"; type Props = { onChange: any; - defaultValue: + showDefaultValue?: boolean; + defaultValue?: | { label: string; value?: number; } | undefined; + creatable?: boolean; }; -export default function CollectionSelection({ onChange, defaultValue }: Props) { +export default function CollectionSelection({ + onChange, + defaultValue, + showDefaultValue = true, + creatable = true, +}: Props) { const { collections } = useCollectionStore(); const router = useRouter(); @@ -36,22 +44,87 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) { useEffect(() => { const formatedCollections = collections.map((e) => { - return { value: e.id, label: e.name, ownerId: e.ownerId }; + return { + value: e.id, + label: e.name, + ownerId: e.ownerId, + count: e._count, + parentId: e.parentId, + }; }); setOptions(formatedCollections); }, [collections]); - return ( - - ); + const getParentNames = (parentId: number): string[] => { + const parentNames = []; + const parent = collections.find((e) => e.id === parentId); + + if (parent) { + parentNames.push(parent.name); + if (parent.parentId) { + parentNames.push(...getParentNames(parent.parentId)); + } + } + + // Have the top level parent at beginning + return parentNames.reverse(); + }; + + const customOption = ({ data, innerProps }: any) => { + return ( +
+
+ {data.label} + {data.count?.links} +
+
+ {getParentNames(data?.parentId).length > 0 ? ( + <> + {getParentNames(data.parentId).join(" > ")} {">"} {data.label} + + ) : ( + data.label + )} +
+
+ ); + }; + + if (creatable) { + return ( + + ); + } else { + return ( + selectedLink.id === link.id + )} + onChange={() => handleCheckboxClick(link)} + /> + )} */} + {!editMode ? ( + <> + +
+
- · - -
-
- - setShowInfo(!showInfo)} - // linkInfo={showInfo} - /> - {showInfo ? ( -
-
-

Description

+
+

+ {unescapeString(link.name || link.description) || link.url} +

-
-

- {link.description ? ( - unescapeString(link.description) - ) : ( - - No description provided. - - )} -

- {link.tags[0] ? ( - <> -

- Tags +

+
+ {collection && ( + + )} + {link.url ? ( +
+ +

{shortendURL}

+
+ ) : ( +
+ {link.type} +
+ )} + +
+
+
+ + setShowInfo(!showInfo)} + // linkInfo={showInfo} + /> + {showInfo && ( +
+
+

+ Description


+

+ {link.description ? ( + unescapeString(link.description) + ) : ( + + No description provided. + + )} +

+ {link.tags[0] && ( + <> +

+ Tags +

-
-
- {link.tags.map((e, i) => ( - { - e.stopPropagation(); - }} - className="btn btn-xs btn-ghost truncate max-w-[19rem]" - > - #{e.name} - - ))} -
+
+ +
+
+ {link.tags.map((e, i) => ( + { + e.stopPropagation(); + }} + className="btn btn-xs btn-ghost truncate max-w-[19rem]" + > + #{e.name} + + ))} +
+
+ + )} +
+
+ )} + + ) : ( + <> +
+
+ +
+ +
+

+ {unescapeString(link.name || link.description) || link.url} +

+ +
+
+ {collection ? ( + + ) : undefined} + {link.url ? ( +
+ +

+ {shortendURL} +

+
+ ) : ( +
+ {link.type} +
+ )} +
- - ) : undefined} +
+
-
- ) : undefined} + setShowInfo(!showInfo)} + // linkInfo={showInfo} + /> + + )}
-
); diff --git a/components/MobileNavigation.tsx b/components/MobileNavigation.tsx new file mode 100644 index 0000000..02fa08a --- /dev/null +++ b/components/MobileNavigation.tsx @@ -0,0 +1,96 @@ +import { dropdownTriggerer, isIphone } from "@/lib/client/utils"; +import React from "react"; +import { useState } from "react"; +import NewLinkModal from "./ModalContent/NewLinkModal"; +import NewCollectionModal from "./ModalContent/NewCollectionModal"; +import UploadFileModal from "./ModalContent/UploadFileModal"; +import MobileNavigationButton from "./MobileNavigationButton"; + +type Props = {}; + +export default function MobileNavigation({}: Props) { + const [newLinkModal, setNewLinkModal] = useState(false); + const [newCollectionModal, setNewCollectionModal] = useState(false); + const [uploadFileModal, setUploadFileModal] = useState(false); + + return ( + <> +
+
+ + +
+
+ + + +
+
    +
  • +
    { + (document?.activeElement as HTMLElement)?.blur(); + setNewLinkModal(true); + }} + tabIndex={0} + role="button" + > + New Link +
    +
  • + {/*
  • +
    { + (document?.activeElement as HTMLElement)?.blur(); + setUploadFileModal(true); + }} + tabIndex={0} + role="button" + > + Upload File +
    +
  • */} +
  • +
    { + (document?.activeElement as HTMLElement)?.blur(); + setNewCollectionModal(true); + }} + tabIndex={0} + role="button" + > + New Collection +
    +
  • +
+
+ + +
+
+ {newLinkModal ? ( + setNewLinkModal(false)} /> + ) : undefined} + {newCollectionModal ? ( + setNewCollectionModal(false)} /> + ) : undefined} + {uploadFileModal ? ( + setUploadFileModal(false)} /> + ) : undefined} + + ); +} diff --git a/components/MobileNavigationButton.tsx b/components/MobileNavigationButton.tsx new file mode 100644 index 0000000..a0a13e1 --- /dev/null +++ b/components/MobileNavigationButton.tsx @@ -0,0 +1,45 @@ +import { isPWA } from "@/lib/client/utils"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; + +export default function MobileNavigationButton({ + href, + icon, +}: { + href: string; + icon: string; +}) { + const router = useRouter(); + const [active, setActive] = useState(false); + + useEffect(() => { + setActive(href === router.asPath); + }, [router]); + + return ( + { + if (isPWA()) { + e.preventDefault(); + e.stopPropagation(); + return false; + } else return null; + }} + > +
+ +
+ + ); +} diff --git a/components/Modal.tsx b/components/Modal.tsx index c68b2e0..6d1bb9f 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -1,5 +1,6 @@ import React, { MouseEventHandler, ReactNode, useEffect } from "react"; import ClickAwayHandler from "@/components/ClickAwayHandler"; +import { Drawer } from "vaul"; type Props = { toggleModal: Function; @@ -8,31 +9,59 @@ type Props = { }; export default function Modal({ toggleModal, className, children }: Props) { - useEffect(() => { - document.body.style.overflow = "hidden"; - return () => { - document.body.style.overflow = "auto"; - }; - }); + const [drawerIsOpen, setDrawerIsOpen] = React.useState(true); - return ( -
- { + if (window.innerWidth >= 640) { + document.body.style.overflow = "hidden"; + document.body.style.position = "relative"; + return () => { + document.body.style.overflow = "auto"; + document.body.style.position = ""; + }; + } + }, []); + + if (window.innerWidth < 640) { + return ( + setTimeout(() => toggleModal(), 100)} > -
-
} - className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" - > - + + + setDrawerIsOpen(false)}> + +
+
+ + {children} +
+ + + + + ); + } else { + return ( +
+ +
+
} + className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10" + > + +
+ {children}
- {children} -
- -
- ); +
+
+ ); + } } diff --git a/components/ModalContent/BulkDeleteLinksModal.tsx b/components/ModalContent/BulkDeleteLinksModal.tsx new file mode 100644 index 0000000..6de26cd --- /dev/null +++ b/components/ModalContent/BulkDeleteLinksModal.tsx @@ -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 ( + +

+ Delete {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""} +

+ +
+ +
+ {selectedLinks.length > 1 ? ( +

Are you sure you want to delete {selectedLinks.length} links?

+ ) : ( +

Are you sure you want to delete this link?

+ )} + +
+ + + Warning: This action is irreversible! + +
+ +

+ Hold the Shift key while clicking + 'Delete' to bypass this confirmation in the future. +

+ + +
+
+ ); +} diff --git a/components/ModalContent/BulkEditLinksModal.tsx b/components/ModalContent/BulkEditLinksModal.tsx new file mode 100644 index 0000000..07b3914 --- /dev/null +++ b/components/ModalContent/BulkEditLinksModal.tsx @@ -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 + >({ 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 ( + +

+ Edit {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""} +

+
+
+
+
+

Move to Collection

+ +
+ +
+

Add Tags

+ +
+
+
+ +
+
+ +
+ +
+
+ ); +} diff --git a/components/ModalContent/DeleteLinkModal.tsx b/components/ModalContent/DeleteLinkModal.tsx index a2b123c..1a3a476 100644 --- a/components/ModalContent/DeleteLinkModal.tsx +++ b/components/ModalContent/DeleteLinkModal.tsx @@ -15,7 +15,6 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) { useState(activeLink); const { removeLink } = useLinkStore(); - const [submitLoader, setSubmitLoader] = useState(false); const router = useRouter(); diff --git a/components/ModalContent/EditCollectionModal.tsx b/components/ModalContent/EditCollectionModal.tsx index ee4c5bc..fbbf53e 100644 --- a/components/ModalContent/EditCollectionModal.tsx +++ b/components/ModalContent/EditCollectionModal.tsx @@ -110,7 +110,7 @@ export default function EditCollectionModal({ className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto" onClick={submit} > - Save + Save Changes
diff --git a/components/ModalContent/EditCollectionSharingModal.tsx b/components/ModalContent/EditCollectionSharingModal.tsx index 7de480f..9a73d5b 100644 --- a/components/ModalContent/EditCollectionSharingModal.tsx +++ b/components/ModalContent/EditCollectionSharingModal.tsx @@ -9,6 +9,7 @@ import usePermissions from "@/hooks/usePermissions"; import ProfilePhoto from "../ProfilePhoto"; import addMemberToCollection from "@/lib/client/addMemberToCollection"; import Modal from "../Modal"; +import { dropdownTriggerer } from "@/lib/client/utils"; type Props = { onClose: Function; @@ -233,11 +234,8 @@ export default function EditCollectionSharingModal({ : undefined; return ( - <> -
+ +
@@ -264,6 +262,7 @@ export default function EditCollectionSharingModal({
{roleLabel} @@ -431,7 +430,7 @@ export default function EditCollectionSharingModal({
- + ); })}
@@ -443,7 +442,7 @@ export default function EditCollectionSharingModal({ className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3" onClick={submit} > - Save + Save Changes )}
diff --git a/components/ModalContent/EditLinkModal.tsx b/components/ModalContent/EditLinkModal.tsx index 9878f4b..b77b76c 100644 --- a/components/ModalContent/EditLinkModal.tsx +++ b/components/ModalContent/EditLinkModal.tsx @@ -124,6 +124,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) { label: "Unorganized", } } + creatable={false} /> ) : null}
@@ -157,7 +158,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) { className="btn btn-accent dark:border-violet-400 text-white" onClick={submit} > - Save + Save Changes
diff --git a/components/ModalContent/NewCollectionModal.tsx b/components/ModalContent/NewCollectionModal.tsx index 8c68c83..80bf7c3 100644 --- a/components/ModalContent/NewCollectionModal.tsx +++ b/components/ModalContent/NewCollectionModal.tsx @@ -5,19 +5,26 @@ import toast from "react-hot-toast"; import { HexColorPicker } from "react-colorful"; import { Collection } from "@prisma/client"; import Modal from "../Modal"; +import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; +import useAccountStore from "@/store/account"; +import { useSession } from "next-auth/react"; type Props = { onClose: Function; + parent?: CollectionIncludingMembersAndLinkCount; }; -export default function NewCollectionModal({ onClose }: Props) { +export default function NewCollectionModal({ onClose, parent }: Props) { const initial = { + parentId: parent?.id, name: "", description: "", color: "#0ea5e9", - }; + } as Partial; const [collection, setCollection] = useState>(initial); + const { setAccount } = useAccountStore(); + const { data } = useSession(); useEffect(() => { setCollection(initial); @@ -39,7 +46,11 @@ export default function NewCollectionModal({ onClose }: Props) { if (response.ok) { toast.success("Created!"); - onClose(); + if (response.data) { + // If the collection was created successfully, we need to get the new collection order + setAccount(data?.user.id as number); + onClose(); + } } else toast.error(response.data as string); setSubmitLoader(false); @@ -47,7 +58,14 @@ export default function NewCollectionModal({ onClose }: Props) { return ( -

Create a New Collection

+ {parent?.id ? ( + <> +

New Sub-Collection

+

For {parent.name}

+ + ) : ( +

Create a New Collection

+ )}
diff --git a/components/ModalContent/NewLinkModal.tsx b/components/ModalContent/NewLinkModal.tsx index 03deba0..46c9ffe 100644 --- a/components/ModalContent/NewLinkModal.tsx +++ b/components/ModalContent/NewLinkModal.tsx @@ -109,7 +109,6 @@ export default function NewLinkModal({ onClose }: Props) { toast.success(`Created!`); onClose(); } else toast.error(response.data as string); - setSubmitLoader(false); return response; @@ -179,7 +178,7 @@ export default function NewLinkModal({ onClose }: Props) { setLink({ ...link, description: e.target.value }) } 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" />
diff --git a/components/ModalContent/NewTokenModal.tsx b/components/ModalContent/NewTokenModal.tsx new file mode 100644 index 0000000..2092eaa --- /dev/null +++ b/components/ModalContent/NewTokenModal.tsx @@ -0,0 +1,227 @@ +import React, { useState } from "react"; +import TextInput from "@/components/TextInput"; +import { TokenExpiry } from "@/types/global"; +import toast from "react-hot-toast"; +import Modal from "../Modal"; +import useTokenStore from "@/store/tokens"; +import { dropdownTriggerer } from "@/lib/client/utils"; + +type Props = { + onClose: Function; +}; + +export default function NewTokenModal({ onClose }: Props) { + const [newToken, setNewToken] = useState(""); + + const { addToken } = useTokenStore(); + + const initial = { + name: "", + expires: 0 as TokenExpiry, + }; + + const [token, setToken] = useState(initial as any); + + const [submitLoader, setSubmitLoader] = useState(false); + + const submit = async () => { + if (!submitLoader) { + setSubmitLoader(true); + + const load = toast.loading("Creating..."); + + const { ok, data } = await addToken(token); + + toast.dismiss(load); + + if (ok) { + toast.success(`Created!`); + setNewToken((data as any).secretKey); + } else toast.error(data as string); + + setSubmitLoader(false); + } + }; + + return ( + + {newToken ? ( +
+

Access Token Created

+

+ Your new token has been created. Please copy it and store it + somewhere safe. You will not be able to see it again. +

+ {}} + className="w-full" + /> + +
+ ) : ( + <> +

Create an Access Token

+ +
+ +
+
+

Name

+ + setToken({ ...token, name: e.target.value })} + placeholder="e.g. For the iOS shortcut" + className="bg-base-200" + /> +
+ +
+

Expires in

+ +
+
+ {token.expires === TokenExpiry.sevenDays && "7 Days"} + {token.expires === TokenExpiry.oneMonth && "30 Days"} + {token.expires === TokenExpiry.twoMonths && "60 Days"} + {token.expires === TokenExpiry.threeMonths && "90 Days"} + {token.expires === TokenExpiry.never && "No Expiration"} +
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+
+ +
+ +
+ + )} +
+ ); +} diff --git a/components/ModalContent/PreservedFormatsModal.tsx b/components/ModalContent/PreservedFormatsModal.tsx index af3053c..5bc181d 100644 --- a/components/ModalContent/PreservedFormatsModal.tsx +++ b/components/ModalContent/PreservedFormatsModal.tsx @@ -217,10 +217,7 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) { {link?.collection.ownerId === session.data?.user.id ? ( -
updateArchive()} - > +
updateArchive()}>

Refresh Preserved Formats

diff --git a/components/ModalContent/RevokeTokenModal.tsx b/components/ModalContent/RevokeTokenModal.tsx new file mode 100644 index 0000000..9aef2d9 --- /dev/null +++ b/components/ModalContent/RevokeTokenModal.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from "react"; +import useLinkStore from "@/store/links"; +import toast from "react-hot-toast"; +import Modal from "../Modal"; +import { useRouter } from "next/router"; +import { AccessToken } from "@prisma/client"; +import useTokenStore from "@/store/tokens"; + +type Props = { + onClose: Function; + activeToken: AccessToken; +}; + +export default function DeleteTokenModal({ onClose, activeToken }: Props) { + const [token, setToken] = useState(activeToken); + + const { revokeToken } = useTokenStore(); + const [submitLoader, setSubmitLoader] = useState(false); + + const router = useRouter(); + + useEffect(() => { + setToken(activeToken); + }, []); + + const deleteLink = async () => { + console.log(token); + const load = toast.loading("Deleting..."); + + const response = await revokeToken(token.id as number); + + toast.dismiss(load); + + response.ok && toast.success(`Token Revoked.`); + + onClose(); + }; + + return ( + +

Revoke Token

+ +
+ +
+

+ Are you sure you want to revoke this Access Token? Any apps or + services using this token will no longer be able to access Linkwarden + using it. +

+ + +
+ + ); +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index fb3678e..04a5d76 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -13,6 +13,8 @@ import NewLinkModal from "./ModalContent/NewLinkModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal"; import Link from "next/link"; import UploadFileModal from "./ModalContent/UploadFileModal"; +import { dropdownTriggerer } from "@/lib/client/utils"; +import MobileNavigation from "./MobileNavigation"; export default function Navbar() { const { settings, updateSettings } = useLocalSettingsStore(); @@ -35,14 +37,12 @@ export default function Navbar() { useEffect(() => { setSidebar(false); - }, [width]); - - useEffect(() => { - setSidebar(false); - }, [router]); + document.body.style.overflow = "auto"; + }, [width, router]); const toggleSidebar = () => { - setSidebar(!sidebar); + setSidebar(false); + document.body.style.overflow = "auto"; }; const [newLinkModal, setNewLinkModal] = useState(false); @@ -52,8 +52,11 @@ export default function Navbar() { return (
{ + setSidebar(true); + document.body.style.overflow = "hidden"; + }} + className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden sm:inline-flex" >
@@ -61,11 +64,12 @@ export default function Navbar() {
-
-
+
+
@@ -117,7 +121,12 @@ export default function Navbar() {
-
+
-
  • +
  • { (document?.activeElement as HTMLElement)?.blur(); @@ -161,6 +170,9 @@ export default function Navbar() {
  • + + + {sidebar ? (
    diff --git a/components/ProfilePhoto.tsx b/components/ProfilePhoto.tsx index ec25a6e..340fa76 100644 --- a/components/ProfilePhoto.tsx +++ b/components/ProfilePhoto.tsx @@ -45,7 +45,7 @@ export default function ProfilePhoto({
    - +
    - + -

    Appearance

    +

    Preference

    - +
    - -

    Archive

    -
    - - - -
    -

    API Keys

    +

    Access Tokens

    diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 75c0686..b5fd88a 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { Disclosure, Transition } from "@headlessui/react"; import SidebarHighlightLink from "@/components/SidebarHighlightLink"; +import CollectionListing from "@/components/CollectionListing"; export default function Sidebar({ className }: { className?: string }) { const [tagDisclosure, setTagDisclosure] = useState(() => { @@ -21,11 +22,10 @@ export default function Sidebar({ className }: { className?: string }) { const { collections } = useCollectionStore(); const { tags } = useTagStore(); + const [active, setActive] = useState(""); const router = useRouter(); - const [active, setActive] = useState(""); - useEffect(() => { localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false"); }, [tagDisclosure]); @@ -44,7 +44,7 @@ export default function Sidebar({ className }: { className?: string }) { return (
    diff --git a/lib/api/archiveHandler.ts b/lib/api/archiveHandler.ts index 60ea790..96a77b2 100644 --- a/lib/api/archiveHandler.ts +++ b/lib/api/archiveHandler.ts @@ -1,4 +1,4 @@ -import { chromium, devices } from "playwright"; +import { LaunchOptions, chromium, devices } from "playwright"; import { prisma } from "./db"; import createFile from "./storage/createFile"; import sendToWayback from "./sendToWayback"; @@ -20,8 +20,23 @@ type LinksAndCollectionAndOwner = Link & { const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5; export default async function archiveHandler(link: LinksAndCollectionAndOwner) { - const browser = await chromium.launch(); - const context = await browser.newContext(devices["Desktop Chrome"]); + // allow user to configure a proxy + let browserOptions: LaunchOptions = {}; + if (process.env.PROXY) { + browserOptions.proxy = { + server: process.env.PROXY, + bypass: process.env.PROXY_BYPASS, + username: process.env.PROXY_USERNAME, + password: process.env.PROXY_PASSWORD, + }; + } + + const browser = await chromium.launch(browserOptions); + const context = await browser.newContext({ + ...devices["Desktop Chrome"], + ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true", + }); + const page = await context.newPage(); const timeoutPromise = new Promise((_, reject) => { @@ -238,6 +253,13 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { }) ); } + + // apply administrator's defined pdf margins or default to 15px + const margins = { + top: process.env.PDF_MARGIN_TOP || "15px", + bottom: process.env.PDF_MARGIN_BOTTOM || "15px", + }; + if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) { processingPromises.push( page @@ -245,7 +267,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) { width: "1366px", height: "1931px", printBackground: true, - margin: { top: "15px", bottom: "15px" }, + margin: margins, }) .then((pdf) => { return createFile({ diff --git a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts index 03870aa..e8fe51a 100644 --- a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts +++ b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts @@ -31,12 +31,16 @@ export default async function deleteCollection( }, }); + await removeFromOrders(userId, 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 deleteSubCollections(collectionId); + await prisma.usersAndCollections.deleteMany({ where: { collection: { @@ -53,7 +57,9 @@ export default async function deleteCollection( }, }); - removeFolder({ filePath: `archives/${collectionId}` }); + await removeFolder({ filePath: `archives/${collectionId}` }); + + await removeFromOrders(userId, collectionId); return await prisma.collection.delete({ where: { @@ -64,3 +70,60 @@ export default async function deleteCollection( 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}` }); + } +} + +async function removeFromOrders(userId: number, collectionId: number) { + const userCollectionOrder = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + collectionOrder: true, + }, + }); + + if (userCollectionOrder) + await prisma.user.update({ + where: { + id: userId, + }, + data: { + collectionOrder: { + set: userCollectionOrder.collectionOrder.filter( + (e: number) => e !== collectionId + ), + }, + }, + }); +} diff --git a/lib/api/controllers/collections/collectionId/getCollectionById.ts b/lib/api/controllers/collections/collectionId/getCollectionById.ts new file mode 100644 index 0000000..46478b8 --- /dev/null +++ b/lib/api/controllers/collections/collectionId/getCollectionById.ts @@ -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 }; +} diff --git a/lib/api/controllers/collections/collectionId/updateCollectionById.ts b/lib/api/controllers/collections/collectionId/updateCollectionById.ts index f257021..4bb4d2f 100644 --- a/lib/api/controllers/collections/collectionId/updateCollectionById.ts +++ b/lib/api/controllers/collections/collectionId/updateCollectionById.ts @@ -1,7 +1,6 @@ import { prisma } from "@/lib/api/db"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import getPermission from "@/lib/api/getPermission"; -import { Collection, UsersAndCollections } from "@prisma/client"; export default async function updateCollection( userId: number, @@ -19,6 +18,32 @@ export default async function updateCollection( if (!(collectionIsAccessible?.ownerId === userId)) return { response: "Collection is not accessible.", status: 401 }; + console.log(data); + + if (data.parentId) { + if (data.parentId !== ("root" as any)) { + const findParentCollection = await prisma.collection.findUnique({ + where: { + id: data.parentId, + }, + select: { + ownerId: true, + parentId: true, + }, + }); + + if ( + findParentCollection?.ownerId !== userId || + typeof data.parentId !== "number" || + findParentCollection?.parentId === data.parentId + ) + return { + response: "You are not authorized to create a sub-collection here.", + status: 403, + }; + } + } + const updatedCollection = await prisma.$transaction(async () => { await prisma.usersAndCollections.deleteMany({ where: { @@ -32,12 +57,23 @@ export default async function updateCollection( where: { id: collectionId, }, - data: { name: data.name.trim(), description: data.description, color: data.color, isPublic: data.isPublic, + parent: + data.parentId && data.parentId !== ("root" as any) + ? { + connect: { + id: data.parentId, + }, + } + : data.parentId === ("root" as any) + ? { + disconnect: true, + } + : undefined, members: { create: data.members.map((e) => ({ user: { connect: { id: e.user.id || e.userId } }, diff --git a/lib/api/controllers/collections/getCollections.ts b/lib/api/controllers/collections/getCollections.ts index 3742163..fa13798 100644 --- a/lib/api/controllers/collections/getCollections.ts +++ b/lib/api/controllers/collections/getCollections.ts @@ -12,6 +12,12 @@ export default async function getCollection(userId: number) { _count: { select: { links: true }, }, + parent: { + select: { + id: true, + name: true, + }, + }, members: { include: { user: { diff --git a/lib/api/controllers/collections/postCollection.ts b/lib/api/controllers/collections/postCollection.ts index 86ca379..0969f1c 100644 --- a/lib/api/controllers/collections/postCollection.ts +++ b/lib/api/controllers/collections/postCollection.ts @@ -12,23 +12,25 @@ export default async function postCollection( status: 400, }; - const findCollection = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - collections: { - where: { - name: collection.name, - }, + if (collection.parentId) { + const findParentCollection = await prisma.collection.findUnique({ + where: { + id: collection.parentId, }, - }, - }); + select: { + ownerId: true, + }, + }); - const checkIfCollectionExists = findCollection?.collections[0]; - - if (checkIfCollectionExists) - return { response: "Collection already exists.", status: 400 }; + if ( + findParentCollection?.ownerId !== userId || + typeof collection.parentId !== "number" + ) + return { + response: "You are not authorized to create a sub-collection here.", + status: 403, + }; + } const newCollection = await prisma.collection.create({ data: { @@ -40,6 +42,13 @@ export default async function postCollection( name: collection.name.trim(), description: collection.description, color: collection.color, + parent: collection.parentId + ? { + connect: { + id: collection.parentId, + }, + } + : undefined, }, include: { _count: { @@ -58,6 +67,17 @@ export default async function postCollection( }, }); + await prisma.user.update({ + where: { + id: userId, + }, + data: { + collectionOrder: { + push: newCollection.id, + }, + }, + }); + createFolder({ filePath: `archives/${newCollection.id}` }); return { response: newCollection, status: 200 }; diff --git a/lib/api/controllers/links/bulk/deleteLinksById.ts b/lib/api/controllers/links/bulk/deleteLinksById.ts new file mode 100644 index 0000000..466db98 --- /dev/null +++ b/lib/api/controllers/links/bulk/deleteLinksById.ts @@ -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 }; +} diff --git a/lib/api/controllers/links/bulk/updateLinks.ts b/lib/api/controllers/links/bulk/updateLinks.ts new file mode 100644 index 0000000..a214c30 --- /dev/null +++ b/lib/api/controllers/links/bulk/updateLinks.ts @@ -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 }; + } +} diff --git a/lib/api/controllers/links/linkId/deleteLinkById.ts b/lib/api/controllers/links/linkId/deleteLinkById.ts index 90adba4..db68ee7 100644 --- a/lib/api/controllers/links/linkId/deleteLinkById.ts +++ b/lib/api/controllers/links/linkId/deleteLinkById.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/api/db"; -import { Collection, Link, UsersAndCollections } from "@prisma/client"; +import { Link, UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import removeFile from "@/lib/api/storage/removeFile"; diff --git a/lib/api/controllers/links/linkId/updateLinkById.ts b/lib/api/controllers/links/linkId/updateLinkById.ts index 7f7fb2e..e6f7f0d 100644 --- a/lib/api/controllers/links/linkId/updateLinkById.ts +++ b/lib/api/controllers/links/linkId/updateLinkById.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/api/db"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; -import { Collection, Link, UsersAndCollections } from "@prisma/client"; +import { UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import moveFile from "@/lib/api/storage/moveFile"; @@ -17,13 +17,70 @@ export default async function updateLinkById( const collectionIsAccessible = await getPermission({ userId, linkId }); + const isCollectionOwner = + collectionIsAccessible?.ownerId === data.collection.ownerId && + data.collection.ownerId === userId; + + const canPinPermission = collectionIsAccessible?.members.some( + (e: UsersAndCollections) => e.userId === userId + ); + + // If the user is able to create a link, they can pin it to their dashboard only. + if (canPinPermission) { + const updatedLink = await prisma.link.update({ + where: { + id: linkId, + }, + data: { + pinnedBy: + data?.pinnedBy && data.pinnedBy[0] + ? { connect: { id: userId } } + : { disconnect: { id: userId } }, + }, + include: { + collection: true, + pinnedBy: isCollectionOwner + ? { + where: { id: userId }, + select: { id: true }, + } + : undefined, + }, + }); + + return { response: updatedLink, status: 200 }; + } + + const targetCollectionIsAccessible = await getPermission({ + userId, + collectionId: data.collection.id, + }); + const memberHasAccess = collectionIsAccessible?.members.some( (e: UsersAndCollections) => e.userId === userId && e.canUpdate ); - const isCollectionOwner = - collectionIsAccessible?.ownerId === data.collection.ownerId && - data.collection.ownerId === userId; + const targetCollectionsAccessible = + targetCollectionIsAccessible?.ownerId === userId; + + const targetCollectionMatchesData = data.collection.id + ? data.collection.id === targetCollectionIsAccessible?.id + : true && data.collection.name + ? data.collection.name === targetCollectionIsAccessible?.name + : true && data.collection.ownerId + ? data.collection.ownerId === targetCollectionIsAccessible?.ownerId + : true; + + if (!targetCollectionsAccessible) + return { + response: "Target collection is not accessible.", + status: 401, + }; + else if (!targetCollectionMatchesData) + return { + response: "Target collection does not match the data.", + status: 401, + }; const unauthorizedSwitchCollection = !isCollectionOwner && collectionIsAccessible?.id !== data.collection.id; diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index 1c82fc2..ec03d2a 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -22,8 +22,114 @@ export default async function postLink( }; } - if (!link.collection.name) { + if (!link.collection.id && link.collection.name) { + link.collection.name = link.collection.name.trim(); + + // find the collection with the name and the user's id + const findCollection = await prisma.collection.findFirst({ + where: { + name: link.collection.name, + ownerId: userId, + parentId: link.collection.parentId, + }, + }); + + if (findCollection) { + const collectionIsAccessible = await getPermission({ + userId, + collectionId: findCollection.id, + }); + + const memberHasAccess = collectionIsAccessible?.members.some( + (e: UsersAndCollections) => e.userId === userId && e.canCreate + ); + + if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) + return { response: "Collection is not accessible.", status: 401 }; + + link.collection.id = findCollection.id; + } else { + const collection = await prisma.collection.create({ + data: { + name: link.collection.name, + ownerId: userId, + }, + }); + + link.collection.id = collection.id; + + await prisma.user.update({ + where: { + id: userId, + }, + data: { + collectionOrder: { + push: link.collection.id, + }, + }, + }); + } + } else if (link.collection.id) { + const collectionIsAccessible = await getPermission({ + userId, + collectionId: link.collection.id, + }); + + const memberHasAccess = collectionIsAccessible?.members.some( + (e: UsersAndCollections) => e.userId === userId && e.canCreate + ); + + if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) + return { response: "Collection is not accessible.", status: 401 }; + } else if (!link.collection.id) { link.collection.name = "Unorganized"; + link.collection.parentId = null; + + // find the collection with the name "Unorganized" and the user's id + const unorganizedCollection = await prisma.collection.findFirst({ + where: { + name: "Unorganized", + ownerId: userId, + }, + }); + + link.collection.id = unorganizedCollection?.id; + + await prisma.user.update({ + where: { + id: userId, + }, + data: { + collectionOrder: { + push: link.collection.id, + }, + }, + }); + } else { + return { response: "Uncaught error.", status: 500 }; + } + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + }); + + if (user?.preventDuplicateLinks) { + const existingLink = await prisma.link.findFirst({ + where: { + url: link.url?.trim(), + collection: { + ownerId: userId, + }, + }, + }); + + if (existingLink) + return { + response: "Link already exists", + status: 409, + }; } const numberOfLinksTheUserHas = await prisma.link.count({ @@ -42,22 +148,6 @@ export default async function postLink( link.collection.name = link.collection.name.trim(); - if (link.collection.id) { - const collectionIsAccessible = await getPermission({ - userId, - collectionId: link.collection.id, - }); - - const memberHasAccess = collectionIsAccessible?.members.some( - (e: UsersAndCollections) => e.userId === userId && e.canCreate - ); - - if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) - return { response: "Collection is not accessible.", status: 401 }; - } else { - link.collection.ownerId = userId; - } - const description = link.description && link.description !== "" ? link.description @@ -81,22 +171,13 @@ export default async function postLink( const newLink = await prisma.link.create({ data: { - url: link.url, + url: link.url?.trim(), name: link.name, description, type: linkType, collection: { - connectOrCreate: { - where: { - name_ownerId: { - ownerId: link.collection.ownerId, - name: link.collection.name, - }, - }, - create: { - name: link.collection.name.trim(), - ownerId: userId, - }, + connect: { + id: link.collection.id, }, }, tags: { diff --git a/lib/api/controllers/migration/exportData.ts b/lib/api/controllers/migration/exportData.ts index 2776d85..73141fd 100644 --- a/lib/api/controllers/migration/exportData.ts +++ b/lib/api/controllers/migration/exportData.ts @@ -13,6 +13,8 @@ export default async function exportData(userId: number) { }, }, }, + pinnedLinks: true, + whitelistedUsers: true, }, }); diff --git a/lib/api/controllers/migration/importFromHTMLFile.ts b/lib/api/controllers/migration/importFromHTMLFile.ts index a2fae27..4398600 100644 --- a/lib/api/controllers/migration/importFromHTMLFile.ts +++ b/lib/api/controllers/migration/importFromHTMLFile.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/api/db"; import createFolder from "@/lib/api/storage/createFolder"; import { JSDOM } from "jsdom"; +import { parse, Node, Element, TextNode } from "himalaya"; const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; @@ -11,6 +12,11 @@ export default async function importFromHTMLFile( const dom = new JSDOM(rawData); const document = dom.window.document; + // remove bad tags + document.querySelectorAll("meta").forEach((e) => (e.outerHTML = e.innerHTML)); + document.querySelectorAll("META").forEach((e) => (e.outerHTML = e.innerHTML)); + document.querySelectorAll("P").forEach((e) => (e.outerHTML = e.innerHTML)); + const bookmarks = document.querySelectorAll("A"); const totalImports = bookmarks.length; @@ -28,94 +34,165 @@ export default async function importFromHTMLFile( status: 400, }; - const folders = document.querySelectorAll("H3"); + const jsonData = parse(document.documentElement.outerHTML); - await prisma - .$transaction( - async () => { - // @ts-ignore - for (const folder of folders) { - const findCollection = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - collections: { - where: { - name: folder.textContent.trim(), - }, - }, - }, - }); - - const checkIfCollectionExists = findCollection?.collections[0]; - - let collectionId = findCollection?.collections[0]?.id; - - if (!checkIfCollectionExists || !collectionId) { - const newCollection = await prisma.collection.create({ - data: { - name: folder.textContent.trim(), - description: "", - color: "#0ea5e9", - isPublic: false, - ownerId: userId, - }, - }); - - createFolder({ filePath: `archives/${newCollection.id}` }); - - collectionId = newCollection.id; - } - - createFolder({ filePath: `archives/${collectionId}` }); - - const bookmarks = folder.nextElementSibling.querySelectorAll("A"); - for (const bookmark of bookmarks) { - await prisma.link.create({ - data: { - name: bookmark.textContent.trim(), - url: bookmark.getAttribute("HREF"), - tags: bookmark.getAttribute("TAGS") - ? { - connectOrCreate: bookmark - .getAttribute("TAGS") - .split(",") - .map((tag: string) => - tag - ? { - where: { - name_ownerId: { - name: tag.trim(), - ownerId: userId, - }, - }, - create: { - name: tag.trim(), - owner: { - connect: { - id: userId, - }, - }, - }, - } - : undefined - ), - } - : undefined, - description: bookmark.getAttribute("DESCRIPTION") - ? bookmark.getAttribute("DESCRIPTION") - : "", - collectionId: collectionId, - createdAt: new Date(), - }, - }); - } - } - }, - { timeout: 30000 } - ) - .catch((err) => console.log(err)); + for (const item of jsonData) { + console.log(item); + await processBookmarks(userId, item as Element); + } return { response: "Success.", status: 200 }; } + +async function processBookmarks( + userId: number, + data: Node, + parentCollectionId?: number +) { + if (data.type === "element") { + for (const item of data.children) { + if (item.type === "element" && item.tagName === "dt") { + // process collection or sub-collection + + let collectionId; + const collectionName = item.children.find( + (e) => e.type === "element" && e.tagName === "h3" + ) as Element; + + if (collectionName) { + collectionId = await createCollection( + userId, + (collectionName.children[0] as TextNode).content, + parentCollectionId + ); + } + await processBookmarks( + userId, + item, + collectionId || parentCollectionId + ); + } else if (item.type === "element" && item.tagName === "a") { + // process link + + const linkUrl = item?.attributes.find((e) => e.key === "href")?.value; + const linkName = ( + item?.children.find((e) => e.type === "text") as TextNode + )?.content; + const linkTags = item?.attributes + .find((e) => e.key === "tags") + ?.value.split(","); + + if (linkUrl && parentCollectionId) { + await createLink( + userId, + linkUrl, + parentCollectionId, + linkName, + "", + linkTags + ); + } else if (linkUrl) { + // create a collection named "Imported Bookmarks" and add the link to it + const collectionId = await createCollection(userId, "Imports"); + + await createLink( + userId, + linkUrl, + collectionId, + linkName, + "", + linkTags + ); + } + + await processBookmarks(userId, item, parentCollectionId); + } else { + // process anything else + await processBookmarks(userId, item, parentCollectionId); + } + } + } +} + +const createCollection = async ( + userId: number, + collectionName: string, + parentId?: number +) => { + const findCollection = await prisma.collection.findFirst({ + where: { + parentId, + name: collectionName, + ownerId: userId, + }, + }); + + if (findCollection) { + return findCollection.id; + } + + const collectionId = await prisma.collection.create({ + data: { + name: collectionName, + parent: parentId + ? { + connect: { + id: parentId, + }, + } + : undefined, + owner: { + connect: { + id: userId, + }, + }, + }, + }); + + createFolder({ filePath: `archives/${collectionId.id}` }); + + return collectionId.id; +}; + +const createLink = async ( + userId: number, + url: string, + collectionId: number, + name?: string, + description?: string, + tags?: string[] +) => { + await prisma.link.create({ + data: { + name: name || "", + url, + description, + collectionId, + tags: + tags && tags[0] + ? { + connectOrCreate: tags.map((tag: string) => { + return ( + { + where: { + name_ownerId: { + name: tag.trim(), + ownerId: userId, + }, + }, + create: { + name: tag.trim(), + owner: { + connect: { + id: userId, + }, + }, + }, + } || undefined + ); + }), + } + : undefined, + }, + }); +}; diff --git a/lib/api/controllers/migration/importFromLinkwarden.ts b/lib/api/controllers/migration/importFromLinkwarden.ts index 51f8ecf..3f91f19 100644 --- a/lib/api/controllers/migration/importFromLinkwarden.ts +++ b/lib/api/controllers/migration/importFromLinkwarden.ts @@ -37,41 +37,20 @@ export default async function importFromLinkwarden( for (const e of data.collections) { e.name = e.name.trim(); - const findCollection = await prisma.user.findUnique({ - where: { - id: userId, - }, - select: { - collections: { - where: { - name: e.name, + const newCollection = await prisma.collection.create({ + data: { + owner: { + connect: { + id: userId, }, }, + name: e.name, + description: e.description, + color: e.color, }, }); - const checkIfCollectionExists = findCollection?.collections[0]; - - let collectionId = findCollection?.collections[0]?.id; - - if (!checkIfCollectionExists) { - const newCollection = await prisma.collection.create({ - data: { - owner: { - connect: { - id: userId, - }, - }, - name: e.name, - description: e.description, - color: e.color, - }, - }); - - createFolder({ filePath: `archives/${newCollection.id}` }); - - collectionId = newCollection.id; - } + createFolder({ filePath: `archives/${newCollection.id}` }); // Import Links for (const link of e.links) { @@ -82,7 +61,7 @@ export default async function importFromLinkwarden( description: link.description, collection: { connect: { - id: collectionId, + id: newCollection.id, }, }, // Import Tags diff --git a/lib/api/controllers/tokens/getTokens.ts b/lib/api/controllers/tokens/getTokens.ts new file mode 100644 index 0000000..a5db351 --- /dev/null +++ b/lib/api/controllers/tokens/getTokens.ts @@ -0,0 +1,21 @@ +import { prisma } from "@/lib/api/db"; + +export default async function getToken(userId: number) { + const getTokens = await prisma.accessToken.findMany({ + where: { + userId, + revoked: false, + }, + select: { + id: true, + name: true, + expires: true, + createdAt: true, + }, + }); + + return { + response: getTokens, + status: 200, + }; +} diff --git a/lib/api/controllers/tokens/postToken.ts b/lib/api/controllers/tokens/postToken.ts new file mode 100644 index 0000000..f88030d --- /dev/null +++ b/lib/api/controllers/tokens/postToken.ts @@ -0,0 +1,92 @@ +import { prisma } from "@/lib/api/db"; +import { TokenExpiry } from "@/types/global"; +import crypto from "crypto"; +import { decode, encode } from "next-auth/jwt"; + +export default async function postToken( + body: { + name: string; + expires: TokenExpiry; + }, + userId: number +) { + console.log(body); + + const checkHasEmptyFields = !body.name || body.expires === undefined; + + if (checkHasEmptyFields) + return { + response: "Please fill out all the fields.", + status: 400, + }; + + const checkIfTokenExists = await prisma.accessToken.findFirst({ + where: { + name: body.name, + revoked: false, + userId, + }, + }); + + if (checkIfTokenExists) { + return { + response: "Token with that name already exists.", + status: 400, + }; + } + + const now = Date.now(); + let expiryDate = new Date(); + const oneDayInSeconds = 86400; + let expiryDateSecond = 7 * oneDayInSeconds; + + if (body.expires === TokenExpiry.oneMonth) { + expiryDate.setDate(expiryDate.getDate() + 30); + expiryDateSecond = 30 * oneDayInSeconds; + } else if (body.expires === TokenExpiry.twoMonths) { + expiryDate.setDate(expiryDate.getDate() + 60); + expiryDateSecond = 60 * oneDayInSeconds; + } else if (body.expires === TokenExpiry.threeMonths) { + expiryDate.setDate(expiryDate.getDate() + 90); + expiryDateSecond = 90 * oneDayInSeconds; + } else if (body.expires === TokenExpiry.never) { + expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never) + expiryDateSecond = 73050 * oneDayInSeconds; + } else { + expiryDate.setDate(expiryDate.getDate() + 7); + expiryDateSecond = 7 * oneDayInSeconds; + } + + const token = await encode({ + token: { + id: userId, + iat: now / 1000, + exp: (expiryDate as any) / 1000, + jti: crypto.randomUUID(), + }, + maxAge: expiryDateSecond || 604800, + secret: process.env.NEXTAUTH_SECRET, + }); + + const tokenBody = await decode({ + token, + secret: process.env.NEXTAUTH_SECRET, + }); + + const createToken = await prisma.accessToken.create({ + data: { + name: body.name, + userId, + token: tokenBody?.jti as string, + expires: expiryDate, + }, + }); + + return { + response: { + secretKey: token, + token: createToken, + }, + status: 200, + }; +} diff --git a/lib/api/controllers/tokens/tokenId/deleteTokenById.ts b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts new file mode 100644 index 0000000..ea17f1f --- /dev/null +++ b/lib/api/controllers/tokens/tokenId/deleteTokenById.ts @@ -0,0 +1,24 @@ +import { prisma } from "@/lib/api/db"; + +export default async function deleteToken(userId: number, tokenId: number) { + if (!tokenId) + return { response: "Please choose a valid token.", status: 401 }; + + const tokenExists = await prisma.accessToken.findFirst({ + where: { + id: tokenId, + userId, + }, + }); + + const revokedToken = await prisma.accessToken.update({ + where: { + id: tokenExists?.id, + }, + data: { + revoked: true, + }, + }); + + return { response: revokedToken, status: 200 }; +} diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 285e44e..f2b5e91 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -183,9 +183,14 @@ export default async function updateUserById( email: data.email?.toLowerCase().trim(), isPrivate: data.isPrivate, image: data.image ? `uploads/avatar/${userId}.jpg` : "", + collectionOrder: data.collectionOrder.filter( + (value, index, self) => self.indexOf(value) === index + ), archiveAsScreenshot: data.archiveAsScreenshot, archiveAsPDF: data.archiveAsPDF, archiveAsWaybackMachine: data.archiveAsWaybackMachine, + linksRouteTo: data.linksRouteTo, + preventDuplicateLinks: data.preventDuplicateLinks, password: data.newPassword && data.newPassword !== "" ? newHashedPassword diff --git a/lib/api/getPermission.ts b/lib/api/getPermission.ts index 61dc5c5..93dd04c 100644 --- a/lib/api/getPermission.ts +++ b/lib/api/getPermission.ts @@ -3,12 +3,14 @@ import { prisma } from "@/lib/api/db"; type Props = { userId: number; collectionId?: number; + collectionName?: string; linkId?: number; }; export default async function getPermission({ userId, collectionId, + collectionName, linkId, }: Props) { if (linkId) { @@ -24,10 +26,11 @@ export default async function getPermission({ }); return check; - } else if (collectionId) { + } else if (collectionId || collectionName) { const check = await prisma.collection.findFirst({ where: { - id: collectionId, + id: collectionId || undefined, + name: collectionName || undefined, OR: [{ ownerId: userId }, { members: { some: { userId } } }], }, include: { members: true }, diff --git a/lib/api/verifyToken.ts b/lib/api/verifyToken.ts new file mode 100644 index 0000000..1c1abfc --- /dev/null +++ b/lib/api/verifyToken.ts @@ -0,0 +1,36 @@ +import { NextApiRequest } from "next"; +import { JWT, getToken } from "next-auth/jwt"; +import { prisma } from "./db"; + +type Props = { + req: NextApiRequest; +}; + +export default async function verifyToken({ + req, +}: Props): Promise { + const token = await getToken({ req }); + const userId = token?.id; + + if (!userId) { + return "You must be logged in."; + } + + if (token.exp < Date.now() / 1000) { + return "Your session has expired, please log in again."; + } + + // check if token is revoked + const revoked = await prisma.accessToken.findFirst({ + where: { + token: token.jti, + revoked: true, + }, + }); + + if (revoked) { + return "Your session has expired, please log in again."; + } + + return token; +} diff --git a/lib/api/verifyUser.ts b/lib/api/verifyUser.ts index db59e6e..847bdaf 100644 --- a/lib/api/verifyUser.ts +++ b/lib/api/verifyUser.ts @@ -1,8 +1,8 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { getToken } from "next-auth/jwt"; import { prisma } from "./db"; import { User } from "@prisma/client"; import verifySubscription from "./verifySubscription"; +import verifyToken from "./verifyToken"; type Props = { req: NextApiRequest; @@ -15,14 +15,15 @@ export default async function verifyUser({ req, res, }: Props): Promise { - const token = await getToken({ req }); - const userId = token?.id; + const token = await verifyToken({ req }); - if (!userId) { - res.status(401).json({ response: "You must be logged in." }); + if (typeof token === "string") { + res.status(401).json({ response: token }); return null; } + const userId = token?.id; + const user = await prisma.user.findUnique({ where: { id: userId, diff --git a/lib/client/generateLinkHref.ts b/lib/client/generateLinkHref.ts new file mode 100644 index 0000000..47c1888 --- /dev/null +++ b/lib/client/generateLinkHref.ts @@ -0,0 +1,39 @@ +import { + AccountSettings, + ArchivedFormat, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; +import { LinksRouteTo } from "@prisma/client"; +import { + pdfAvailable, + readabilityAvailable, + screenshotAvailable, +} from "../shared/getArchiveValidity"; + +export const generateLinkHref = ( + link: LinkIncludingShortenedCollectionAndTags, + account: AccountSettings +): string => { + // Return the links href based on the account's preference + // If the user's preference is not available, return the original link + switch (account.linksRouteTo) { + case LinksRouteTo.ORIGINAL: + return link.url || ""; + case LinksRouteTo.PDF: + if (!pdfAvailable(link)) return link.url || ""; + + return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`; + case LinksRouteTo.READABLE: + if (!readabilityAvailable(link)) return link.url || ""; + + return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`; + case LinksRouteTo.SCREENSHOT: + if (!screenshotAvailable(link)) return link.url || ""; + + return `/preserved/${link?.id}?format=${ + link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg + }`; + default: + return link.url || ""; + } +}; diff --git a/lib/client/utils.ts b/lib/client/utils.ts new file mode 100644 index 0000000..7d139c5 --- /dev/null +++ b/lib/client/utils.ts @@ -0,0 +1,20 @@ +export function isPWA() { + return ( + window.matchMedia("(display-mode: standalone)").matches || + (window.navigator as any).standalone || + document.referrer.includes("android-app://") + ); +} + +export function isIphone() { + return /iPhone/.test(navigator.userAgent) && !(window as any).MSStream; +} + +export function dropdownTriggerer(e: any) { + let targetEl = e.currentTarget; + if (targetEl && targetEl.matches(":focus")) { + setTimeout(function () { + targetEl.blur(); + }, 0); + } +} diff --git a/lib/shared/getArchiveValidity.ts b/lib/shared/getArchiveValidity.ts index 395de00..0da5504 100644 --- a/lib/shared/getArchiveValidity.ts +++ b/lib/shared/getArchiveValidity.ts @@ -1,4 +1,8 @@ -export function screenshotAvailable(link: any) { +import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; + +export function screenshotAvailable( + link: LinkIncludingShortenedCollectionAndTags +) { return ( link && link.image && @@ -7,13 +11,15 @@ export function screenshotAvailable(link: any) { ); } -export function pdfAvailable(link: any) { +export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) { return ( link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable" ); } -export function readabilityAvailable(link: any) { +export function readabilityAvailable( + link: LinkIncludingShortenedCollectionAndTags +) { return ( link && link.readable && diff --git a/lib/shared/getTitle.ts b/lib/shared/getTitle.ts index 4c2a5d0..82fee37 100644 --- a/lib/shared/getTitle.ts +++ b/lib/shared/getTitle.ts @@ -1,5 +1,7 @@ import fetch from "node-fetch"; import https from "https"; +import { SocksProxyAgent } from "socks-proxy-agent"; + export default async function getTitle(url: string) { try { const httpsAgent = new https.Agent({ @@ -7,9 +9,26 @@ export default async function getTitle(url: string) { process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true, }); - const response = await fetch(url, { + // fetchOpts allows a proxy to be defined + let fetchOpts = { agent: httpsAgent, - }); + }; + + if (process.env.PROXY) { + // parse proxy url + let proxy = new URL(process.env.PROXY); + // if authentication set, apply to proxy URL + if (process.env.PROXY_USERNAME) { + proxy.username = process.env.PROXY_USERNAME; + proxy.password = process.env.PROXY_PASSWORD || ""; + } + + // add socks5 proxy to fetchOpts + fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies + } + + const response = await fetch(url, fetchOpts); + const text = await response.text(); // regular expression to find the tag diff --git a/package.json b/package.json index a969da6..1f17a6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "linkwarden", - "version": "2.4.9", + "version": "2.5.0", "main": "index.js", "repository": "https://github.com/linkwarden/linkwarden.git", "author": "Daniel31X13 <daniel31x13@gmail.com>", @@ -19,6 +19,7 @@ "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"" }, "dependencies": { + "@atlaskit/tree": "^8.8.7", "@auth/prisma-adapter": "^1.0.1", "@aws-sdk/client-s3": "^3.379.1", "@headlessui/react": "^1.7.15", @@ -44,12 +45,14 @@ "eslint-config-next": "13.4.9", "formidable": "^3.5.1", "framer-motion": "^10.16.4", + "himalaya": "^1.1.0", "jimp": "^0.22.10", "jsdom": "^22.1.0", "lottie-web": "^5.12.2", "micro": "^10.0.1", "next": "13.4.12", "next-auth": "^4.22.1", + "node-fetch": "^2.7.0", "nodemailer": "^6.9.3", "playwright": "^1.35.1", "react": "18.2.0", @@ -58,7 +61,9 @@ "react-hot-toast": "^2.4.1", "react-image-file-resizer": "^0.4.8", "react-select": "^5.7.4", + "socks-proxy-agent": "^8.0.2", "stripe": "^12.13.0", + "vaul": "^0.8.8", "zustand": "^4.3.8" }, "devDependencies": { diff --git a/pages/_app.tsx b/pages/_app.tsx index 60c15c3..b965941 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import "@/styles/globals.css"; import "bootstrap-icons/font/bootstrap-icons.css"; import { SessionProvider } from "next-auth/react"; @@ -7,6 +7,7 @@ import Head from "next/head"; import AuthRedirect from "@/layouts/AuthRedirect"; import { Toaster } from "react-hot-toast"; import { Session } from "next-auth"; +import { isPWA } from "@/lib/client/utils"; export default function App({ Component, @@ -14,6 +15,15 @@ export default function App({ }: AppProps<{ session: Session; }>) { + useEffect(() => { + if (isPWA()) { + const meta = document.createElement("meta"); + meta.name = "viewport"; + meta.content = "width=device-width, initial-scale=1, maximum-scale=1"; + document.getElementsByTagName("head")[0].appendChild(meta); + } + }, []); + return ( <SessionProvider session={pageProps.session} @@ -23,6 +33,7 @@ export default function App({ <Head> <title>Linkwarden + (Sort.DateNewestFirst); @@ -78,12 +84,24 @@ export default function Index() { }; fetchOwner(); + + // When the collection changes, reset the selected links + setSelectedLinks([]); }, [activeCollection]); const [editCollectionModal, setEditCollectionModal] = useState(false); + const [newCollectionModal, setNewCollectionModal] = useState(false); const [editCollectionSharingModal, setEditCollectionSharingModal] = useState(false); const [deleteCollectionModal, setDeleteCollectionModal] = useState(false); + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + useEffect(() => { + return () => { + setEditMode(false); + }; + }, [router]); const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card @@ -98,6 +116,35 @@ export default function Index() { // @ts-ignore const LinkComponent = linkView[viewMode]; + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + return (
      - {permissions === true ? ( + {permissions === true && (
    • - ) : undefined} + )}
    • + {permissions === true && ( +
    • +
      { + (document?.activeElement as HTMLElement)?.blur(); + setNewCollectionModal(true); + }} + > + Create Sub-Collection +
      +
    • + )}
    • )} - {activeCollection ? ( + {activeCollection && (

      By {collectionOwner.name} - {activeCollection.members.length > 0 - ? ` and ${activeCollection.members.length} others` - : undefined} + {activeCollection.members.length > 0 && + ` and ${activeCollection.members.length} others`} .

      - ) : undefined} + )} - {activeCollection?.description ? ( + {activeCollection?.description && (

      {activeCollection?.description}

      - ) : undefined} + )} + + {/* {collections.some((e) => e.parentId === activeCollection.id) ? ( +
      + Sub-Collections +
      + {collections + .filter((e) => e.parentId === activeCollection?.id) + .map((e, i) => { + return ( + + +

      {e.name}

      + + ); + })} +
      +
      + ) : undefined} */}
      -
      +

      Showing {activeCollection?._count?.links} results

      + {links.length > 0 && + (permissions === true || + permissions?.canUpdate || + permissions?.canDelete) && ( +
      { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
      + )}
      + {editMode && links.length > 0 && ( +
      + {links.length > 0 && ( +
      + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + + ) : ( + Nothing selected + )} +
      + )} +
      + + +
      +
      + )} + {links.some((e) => e.collectionId === Number(router.query.id)) ? ( e.collection.id === activeCollection?.id )} @@ -246,28 +404,48 @@ export default function Index() { )}
      - {activeCollection ? ( + {activeCollection && ( <> - {editCollectionModal ? ( + {editCollectionModal && ( setEditCollectionModal(false)} activeCollection={activeCollection} /> - ) : undefined} - {editCollectionSharingModal ? ( + )} + {editCollectionSharingModal && ( setEditCollectionSharingModal(false)} activeCollection={activeCollection} /> - ) : undefined} - {deleteCollectionModal ? ( + )} + {newCollectionModal && ( + setNewCollectionModal(false)} + parent={activeCollection} + /> + )} + {deleteCollectionModal && ( setDeleteCollectionModal(false)} activeCollection={activeCollection} /> - ) : undefined} + )} + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} - ) : undefined} + )} ); } diff --git a/pages/collections/index.tsx b/pages/collections/index.tsx index 923869f..f5ed526 100644 --- a/pages/collections/index.tsx +++ b/pages/collections/index.tsx @@ -11,7 +11,6 @@ import PageHeader from "@/components/PageHeader"; export default function Collections() { const { collections } = useCollectionStore(); - const [expandDropdown, setExpandDropdown] = useState(false); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); const [sortedCollections, setSortedCollections] = useState(collections); @@ -40,7 +39,7 @@ export default function Collections() {
      {sortedCollections - .filter((e) => e.ownerId === data?.user.id) + .filter((e) => e.ownerId === data?.user.id && e.parentId === null) .map((e, i) => { return ; })} diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 18973c7..ec5dc78 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -2,7 +2,6 @@ import useLinkStore from "@/store/links"; import useCollectionStore from "@/store/collections"; import useTagStore from "@/store/tags"; import MainLayout from "@/layouts/MainLayout"; -import LinkCard from "@/components/LinkViews/LinkCard"; import { useEffect, useState } from "react"; import useLinks from "@/hooks/useLinks"; import Link from "next/link"; @@ -16,6 +15,7 @@ import PageHeader from "@/components/PageHeader"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; import ViewDropdown from "@/components/ViewDropdown"; +import { dropdownTriggerer } from "@/lib/client/utils"; // import GridView from "@/components/LinkViews/Layouts/GridView"; export default function Dashboard() { @@ -168,7 +168,10 @@ export default function Dashboard() { > {links[0] ? (
      - +
      ) : (
      @@ -277,14 +281,12 @@ export default function Dashboard() { > {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
      -
      - {links + e.pinnedBy && e.pinnedBy[0]) - .map((e, i) => ) .slice(0, showLinks)} -
      + />
      ) : (
      ( localStorage.getItem("viewMode") || ViewMode.Card ); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); + const router = useRouter(); + + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + useEffect(() => { + return () => { + setEditMode(false); + }; + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + useLinks({ sort: sortBy }); + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + const linkView = { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, @@ -41,17 +91,105 @@ export default function Links() { />
      + {links.length > 0 && ( +
      { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
      + )}
      + {editMode && links.length > 0 && ( +
      + {links.length > 0 && ( +
      + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + + ) : ( + Nothing selected + )} +
      + )} +
      + + +
      +
      + )} + {links[0] ? ( - + ) : ( )}
      + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} ); } diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx index c6b5ee0..f07f81a 100644 --- a/pages/links/pinned.tsx +++ b/pages/links/pinned.tsx @@ -2,16 +2,22 @@ import SortDropdown from "@/components/SortDropdown"; import useLinks from "@/hooks/useLinks"; import MainLayout from "@/layouts/MainLayout"; import useLinkStore from "@/store/links"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import PageHeader from "@/components/PageHeader"; import { Sort, ViewMode } from "@/types/global"; import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; import ListView from "@/components/LinkViews/Layouts/ListView"; +import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; +import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; +import useCollectivePermissions from "@/hooks/useCollectivePermissions"; +import toast from "react-hot-toast"; // import GridView from "@/components/LinkViews/Layouts/GridView"; +import { useRouter } from "next/router"; export default function PinnedLinks() { - const { links } = useLinkStore(); + const { links, selectedLinks, deleteLinksById, setSelectedLinks } = + useLinkStore(); const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card @@ -20,6 +26,49 @@ export default function PinnedLinks() { useLinks({ sort: sortBy, pinnedOnly: true }); + const router = useRouter(); + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + useEffect(() => { + return () => { + setEditMode(false); + }; + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + const linkView = { [ViewMode.Card]: CardView, // [ViewMode.Grid]: GridView, @@ -39,13 +88,87 @@ export default function PinnedLinks() { description={"Pinned Links from your Collections"} />
      + {!(links.length === 0) && ( +
      { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
      + )}
      + {editMode && links.length > 0 && ( +
      + {links.length > 0 && ( +
      + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + + ) : ( + Nothing selected + )} +
      + )} +
      + + +
      +
      + )} + {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( - + ) : (
      )}
      + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} ); } diff --git a/pages/login.tsx b/pages/login.tsx index 796da86..5b528b9 100644 --- a/pages/login.tsx +++ b/pages/login.tsx @@ -170,6 +170,13 @@ export default function Login({ {displayLoginCredential()} {displayLoginExternalButton()} {displayRegistration()} + + You can install Linkwarden onto your device +
      diff --git a/pages/search.tsx b/pages/search.tsx index 32dbf96..ec686bf 100644 --- a/pages/search.tsx +++ b/pages/search.tsx @@ -25,8 +25,6 @@ export default function Search() { tags: true, }); - const [filterDropdown, setFilterDropdown] = useState(false); - const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card ); diff --git a/pages/settings/access-tokens.tsx b/pages/settings/access-tokens.tsx new file mode 100644 index 0000000..1204ce8 --- /dev/null +++ b/pages/settings/access-tokens.tsx @@ -0,0 +1,107 @@ +import SettingsLayout from "@/layouts/SettingsLayout"; +import React, { useEffect, useState } from "react"; +import NewTokenModal from "@/components/ModalContent/NewTokenModal"; +import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal"; +import { AccessToken } from "@prisma/client"; +import useTokenStore from "@/store/tokens"; + +export default function AccessTokens() { + const [newTokenModal, setNewTokenModal] = useState(false); + const [revokeTokenModal, setRevokeTokenModal] = useState(false); + const [selectedToken, setSelectedToken] = useState(null); + + const openRevokeModal = (token: AccessToken) => { + setSelectedToken(token); + setRevokeTokenModal(true); + }; + + const { setTokens, tokens } = useTokenStore(); + + useEffect(() => { + fetch("/api/v1/tokens") + .then((res) => res.json()) + .then((data) => { + if (data.response) setTokens(data.response as AccessToken[]); + }); + }, []); + + return ( + +

      Access Tokens

      + +
      + +
      +

      + Access Tokens can be used to access Linkwarden from other apps and + services without giving away your Username and Password. +

      + + + + {tokens.length > 0 ? ( + <> +
      + + + {/* head */} + + + + + + + + + + + {tokens.map((token, i) => ( + + + + + + + + + + ))} + +
      NameCreatedExpires
      {i + 1}{token.name} + {new Date(token.createdAt || "").toLocaleDateString()} + + {new Date(token.expires || "").toLocaleDateString()} + + +
      + + ) : undefined} +
      + + {newTokenModal ? ( + setNewTokenModal(false)} /> + ) : undefined} + {revokeTokenModal && selectedToken && ( + { + setRevokeTokenModal(false); + setSelectedToken(null); + }} + activeToken={selectedToken} + /> + )} +
      + ); +} diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx index dcd0a87..8d3134e 100644 --- a/pages/settings/account.tsx +++ b/pages/settings/account.tsx @@ -11,6 +11,7 @@ import React from "react"; import { MigrationFormat, MigrationRequest } from "@/types/global"; import Link from "next/link"; import Checkbox from "@/components/Checkbox"; +import { dropdownTriggerer } from "@/lib/client/utils"; export default function Account() { const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; @@ -191,8 +192,8 @@ export default function Account() { ) : undefined}
      -
      -

      Profile Photo

      +
      +

      Profile Photo

      @@ -347,8 +349,8 @@ export default function Account() {
      @@ -373,7 +375,7 @@ export default function Account() {

      Delete Your Account

      diff --git a/pages/settings/api.tsx b/pages/settings/api.tsx deleted file mode 100644 index dc4bb9a..0000000 --- a/pages/settings/api.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import Checkbox from "@/components/Checkbox"; -import SubmitButton from "@/components/SubmitButton"; -import SettingsLayout from "@/layouts/SettingsLayout"; -import React, { useEffect, useState } from "react"; -import useAccountStore from "@/store/account"; -import { toast } from "react-hot-toast"; -import { AccountSettings } from "@/types/global"; -import TextInput from "@/components/TextInput"; - -export default function Api() { - const [submitLoader, setSubmitLoader] = useState(false); - const { account, updateAccount } = useAccountStore(); - const [user, setUser] = useState(account); - - const [archiveAsScreenshot, setArchiveAsScreenshot] = - useState(false); - const [archiveAsPDF, setArchiveAsPDF] = useState(false); - const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] = - useState(false); - - useEffect(() => { - setUser({ - ...account, - archiveAsScreenshot, - archiveAsPDF, - archiveAsWaybackMachine, - }); - }, [account, archiveAsScreenshot, archiveAsPDF, archiveAsWaybackMachine]); - - function objectIsEmpty(obj: object) { - return Object.keys(obj).length === 0; - } - - useEffect(() => { - if (!objectIsEmpty(account)) { - setArchiveAsScreenshot(account.archiveAsScreenshot); - setArchiveAsPDF(account.archiveAsPDF); - setArchiveAsWaybackMachine(account.archiveAsWaybackMachine); - } - }, [account]); - - const submit = async () => { - // setSubmitLoader(true); - // const load = toast.loading("Applying..."); - // const response = await updateAccount({ - // ...user, - // }); - // toast.dismiss(load); - // if (response.ok) { - // toast.success("Settings Applied!"); - // } else toast.error(response.data as string); - // setSubmitLoader(false); - }; - - return ( - -

      API Keys (Soon)

      - -
      - -
      -
      - Status: Under Development -
      - -

      This page will be for creating and managing your API keys.

      - -

      - For now, you can temporarily use your{" "} - - next-auth.session-token - {" "} - in your browser cookies as the API key for your integrations. -

      -
      -
      - ); -} diff --git a/pages/settings/appearance.tsx b/pages/settings/appearance.tsx deleted file mode 100644 index 385225a..0000000 --- a/pages/settings/appearance.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import SettingsLayout from "@/layouts/SettingsLayout"; -import { useState, useEffect } from "react"; -import useAccountStore from "@/store/account"; -import { AccountSettings } from "@/types/global"; -import { toast } from "react-hot-toast"; -import React from "react"; -import useLocalSettingsStore from "@/store/localSettings"; - -export default function Appearance() { - const { updateSettings } = useLocalSettingsStore(); - const submit = async () => { - setSubmitLoader(true); - - const load = toast.loading("Applying..."); - - const response = await updateAccount({ - ...user, - }); - - toast.dismiss(load); - - if (response.ok) { - toast.success("Settings Applied!"); - } else toast.error(response.data as string); - setSubmitLoader(false); - }; - - const [submitLoader, setSubmitLoader] = useState(false); - - const { account, updateAccount } = useAccountStore(); - - const [user, setUser] = useState( - !objectIsEmpty(account) - ? account - : ({ - // @ts-ignore - id: null, - name: "", - username: "", - email: "", - emailVerified: null, - blurredFavicons: null, - image: "", - isPrivate: true, - // @ts-ignore - createdAt: null, - whitelistedUsers: [], - } as unknown as AccountSettings) - ); - - function objectIsEmpty(obj: object) { - return Object.keys(obj).length === 0; - } - - useEffect(() => { - if (!objectIsEmpty(account)) setUser({ ...account }); - }, [account]); - - return ( - -

      Appearance

      - -
      - -
      -
      -

      Select Theme

      -
      -
      updateSettings({ theme: "dark" })} - > - -

      Dark

      - - {/*
      */} -
      -
      updateSettings({ theme: "light" })} - > - -

      Light

      - {/*
      */} -
      -
      -
      - - {/* */} -
      -
      - ); -} diff --git a/pages/settings/archive.tsx b/pages/settings/archive.tsx deleted file mode 100644 index f664638..0000000 --- a/pages/settings/archive.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import Checkbox from "@/components/Checkbox"; -import SubmitButton from "@/components/SubmitButton"; -import SettingsLayout from "@/layouts/SettingsLayout"; -import React, { useEffect, useState } from "react"; -import useAccountStore from "@/store/account"; -import { toast } from "react-hot-toast"; -import { AccountSettings } from "@/types/global"; - -export default function Archive() { - const [submitLoader, setSubmitLoader] = useState(false); - const { account, updateAccount } = useAccountStore(); - const [user, setUser] = useState(account); - - const [archiveAsScreenshot, setArchiveAsScreenshot] = - useState(false); - const [archiveAsPDF, setArchiveAsPDF] = useState(false); - const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] = - useState(false); - - useEffect(() => { - setUser({ - ...account, - archiveAsScreenshot, - archiveAsPDF, - archiveAsWaybackMachine, - }); - }, [account, archiveAsScreenshot, archiveAsPDF, archiveAsWaybackMachine]); - - function objectIsEmpty(obj: object) { - return Object.keys(obj).length === 0; - } - - useEffect(() => { - if (!objectIsEmpty(account)) { - setArchiveAsScreenshot(account.archiveAsScreenshot); - setArchiveAsPDF(account.archiveAsPDF); - setArchiveAsWaybackMachine(account.archiveAsWaybackMachine); - } - }, [account]); - - const submit = async () => { - setSubmitLoader(true); - - const load = toast.loading("Applying..."); - - const response = await updateAccount({ - ...user, - }); - - toast.dismiss(load); - - if (response.ok) { - toast.success("Settings Applied!"); - } else toast.error(response.data as string); - setSubmitLoader(false); - }; - - return ( - -

      Archive Settings

      - -
      - -

      Formats to Archive/Preserve webpages:

      -
      - setArchiveAsScreenshot(!archiveAsScreenshot)} - /> - - setArchiveAsPDF(!archiveAsPDF)} - /> - - setArchiveAsWaybackMachine(!archiveAsWaybackMachine)} - /> -
      - - -
      - ); -} diff --git a/pages/settings/password.tsx b/pages/settings/password.tsx index 75f20cd..ee8af3b 100644 --- a/pages/settings/password.tsx +++ b/pages/settings/password.tsx @@ -77,8 +77,8 @@ export default function Password() {
      diff --git a/pages/settings/preference.tsx b/pages/settings/preference.tsx new file mode 100644 index 0000000..1d5a2e0 --- /dev/null +++ b/pages/settings/preference.tsx @@ -0,0 +1,237 @@ +import SettingsLayout from "@/layouts/SettingsLayout"; +import { useState, useEffect } from "react"; +import useAccountStore from "@/store/account"; +import { AccountSettings } from "@/types/global"; +import { toast } from "react-hot-toast"; +import React from "react"; +import useLocalSettingsStore from "@/store/localSettings"; +import Checkbox from "@/components/Checkbox"; +import SubmitButton from "@/components/SubmitButton"; +import { LinksRouteTo } from "@prisma/client"; + +export default function Appearance() { + const { updateSettings } = useLocalSettingsStore(); + + const [submitLoader, setSubmitLoader] = useState(false); + const { account, updateAccount } = useAccountStore(); + const [user, setUser] = useState(account); + + const [preventDuplicateLinks, setPreventDuplicateLinks] = + useState(false); + const [archiveAsScreenshot, setArchiveAsScreenshot] = + useState(false); + const [archiveAsPDF, setArchiveAsPDF] = useState(false); + const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] = + useState(false); + const [linksRouteTo, setLinksRouteTo] = useState( + user.linksRouteTo + ); + + useEffect(() => { + setUser({ + ...account, + archiveAsScreenshot, + archiveAsPDF, + archiveAsWaybackMachine, + linksRouteTo, + preventDuplicateLinks, + }); + }, [ + account, + archiveAsScreenshot, + archiveAsPDF, + archiveAsWaybackMachine, + linksRouteTo, + preventDuplicateLinks, + ]); + + function objectIsEmpty(obj: object) { + return Object.keys(obj).length === 0; + } + + useEffect(() => { + if (!objectIsEmpty(account)) { + setArchiveAsScreenshot(account.archiveAsScreenshot); + setArchiveAsPDF(account.archiveAsPDF); + setArchiveAsWaybackMachine(account.archiveAsWaybackMachine); + setLinksRouteTo(account.linksRouteTo); + setPreventDuplicateLinks(account.preventDuplicateLinks); + } + }, [account]); + + const submit = async () => { + setSubmitLoader(true); + + const load = toast.loading("Applying..."); + + const response = await updateAccount({ + ...user, + }); + + toast.dismiss(load); + + if (response.ok) { + toast.success("Settings Applied!"); + } else toast.error(response.data as string); + setSubmitLoader(false); + }; + + return ( + +

      Preference

      + +
      + +
      +
      +

      Select Theme

      +
      +
      updateSettings({ theme: "dark" })} + > + +

      Dark

      + + {/*
      */} +
      +
      updateSettings({ theme: "light" })} + > + +

      Light

      + {/*
      */} +
      +
      +
      + +
      +

      + Archive Settings +

      + +
      + +

      Formats to Archive/Preserve webpages:

      +
      + setArchiveAsScreenshot(!archiveAsScreenshot)} + /> + + setArchiveAsPDF(!archiveAsPDF)} + /> + + + setArchiveAsWaybackMachine(!archiveAsWaybackMachine) + } + /> +
      +
      + +
      +

      Link Settings

      + +
      +
      + setPreventDuplicateLinks(!preventDuplicateLinks)} + /> +
      + +

      Clicking on Links should:

      +
      + + + + + + + +
      +
      + + +
      +
      + ); +} diff --git a/pages/tags/[id].tsx b/pages/tags/[id].tsx index a4cbb69..1d527f4 100644 --- a/pages/tags/[id].tsx +++ b/pages/tags/[id].tsx @@ -1,6 +1,6 @@ import useLinkStore from "@/store/links"; import { useRouter } from "next/router"; -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, use, useEffect, useState } from "react"; import MainLayout from "@/layouts/MainLayout"; import useTagStore from "@/store/tags"; import SortDropdown from "@/components/SortDropdown"; @@ -11,11 +11,16 @@ import ViewDropdown from "@/components/ViewDropdown"; import CardView from "@/components/LinkViews/Layouts/CardView"; // import GridView from "@/components/LinkViews/Layouts/GridView"; import ListView from "@/components/LinkViews/Layouts/ListView"; +import { dropdownTriggerer } from "@/lib/client/utils"; +import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal"; +import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal"; +import useCollectivePermissions from "@/hooks/useCollectivePermissions"; export default function Index() { const router = useRouter(); - const { links } = useLinkStore(); + const { links, selectedLinks, deleteLinksById, setSelectedLinks } = + useLinkStore(); const { tags, updateTag, removeTag } = useTagStore(); const [sortBy, setSortBy] = useState(Sort.DateNewestFirst); @@ -25,11 +30,31 @@ export default function Index() { const [activeTag, setActiveTag] = useState(); + const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false); + const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false); + const [editMode, setEditMode] = useState(false); + useEffect(() => { + return () => { + setEditMode(false); + }; + }, [router]); + + const collectivePermissions = useCollectivePermissions( + selectedLinks.map((link) => link.collectionId as number) + ); + useLinks({ tagId: Number(router.query.id), sort: sortBy }); useEffect(() => { - setActiveTag(tags.find((e) => e.id === Number(router.query.id))); - }, [router, tags]); + const tag = tags.find((e) => e.id === Number(router.query.id)); + + if (tags.length > 0 && !tag?.id) { + router.push("/dashboard"); + return; + } + + setActiveTag(tag); + }, [router, tags, Number(router.query.id), setActiveTag]); useEffect(() => { setNewTagName(activeTag?.name); @@ -90,6 +115,35 @@ export default function Index() { setRenameTag(false); }; + const handleSelectAll = () => { + if (selectedLinks.length === links.length) { + setSelectedLinks([]); + } else { + setSelectedLinks(links.map((link) => link)); + } + }; + + const bulkDeleteLinks = async () => { + const load = toast.loading( + `Deleting ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }...` + ); + + const response = await deleteLinksById( + selectedLinks.map((link) => link.id as number) + ); + + toast.dismiss(load); + + response.ok && + toast.success( + `Deleted ${selectedLinks.length} Link${ + selectedLinks.length > 1 ? "s" : "" + }!` + ); + }; + const [viewMode, setViewMode] = useState( localStorage.getItem("viewMode") || ViewMode.Card ); @@ -153,6 +207,7 @@ export default function Index() {
      +
      { + setEditMode(!editMode); + setSelectedLinks([]); + }} + className={`btn btn-square btn-sm btn-ghost ${ + editMode + ? "bg-primary/20 hover:bg-primary/20" + : "hover:bg-neutral/20" + }`} + > + +
      + {editMode && links.length > 0 && ( +
      + {links.length > 0 && ( +
      + handleSelectAll()} + checked={ + selectedLinks.length === links.length && links.length > 0 + } + /> + {selectedLinks.length > 0 ? ( + + {selectedLinks.length}{" "} + {selectedLinks.length === 1 ? "link" : "links"} selected + + ) : ( + Nothing selected + )} +
      + )} +
      + + +
      +
      + )} e.tags.some((e) => e.id === Number(router.query.id)) )} />
      + {bulkDeleteLinksModal && ( + { + setBulkDeleteLinksModal(false); + }} + /> + )} + {bulkEditLinksModal && ( + { + setBulkEditLinksModal(false); + }} + /> + )} ); } diff --git a/prisma/migrations/20240113051701_make_key_names_unique/migration.sql b/prisma/migrations/20240113051701_make_key_names_unique/migration.sql new file mode 100644 index 0000000..55efb95 --- /dev/null +++ b/prisma/migrations/20240113051701_make_key_names_unique/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_name_key" ON "ApiKey"("name"); diff --git a/prisma/migrations/20240113060555_minor_fix/migration.sql b/prisma/migrations/20240113060555_minor_fix/migration.sql new file mode 100644 index 0000000..d3999b6 --- /dev/null +++ b/prisma/migrations/20240113060555_minor_fix/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[name,userId]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "ApiKey_name_key"; + +-- DropIndex +DROP INDEX "ApiKey_token_userId_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_name_userId_key" ON "ApiKey"("name", "userId"); diff --git a/prisma/migrations/20240124192212_added_revoke_field/migration.sql b/prisma/migrations/20240124192212_added_revoke_field/migration.sql new file mode 100644 index 0000000..b9802a0 --- /dev/null +++ b/prisma/migrations/20240124192212_added_revoke_field/migration.sql @@ -0,0 +1,35 @@ +/* + Warnings: + + - You are about to drop the `ApiKey` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_userId_fkey"; + +-- DropTable +DROP TABLE "ApiKey"; + +-- CreateTable +CREATE TABLE "AccessToken" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "revoked" BOOLEAN NOT NULL DEFAULT false, + "expires" TIMESTAMP(3) NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AccessToken_token_key" ON "AccessToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "AccessToken_name_userId_key" ON "AccessToken"("name", "userId"); + +-- AddForeignKey +ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql b/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql new file mode 100644 index 0000000..4a570db --- /dev/null +++ b/prisma/migrations/20240124201018_removed_name_unique_constraint/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "AccessToken_name_userId_key"; diff --git a/prisma/migrations/20240125124457_added_subcollection_relations/migration.sql b/prisma/migrations/20240125124457_added_subcollection_relations/migration.sql new file mode 100644 index 0000000..9479956 --- /dev/null +++ b/prisma/migrations/20240125124457_added_subcollection_relations/migration.sql @@ -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; diff --git a/prisma/migrations/20240207152849_add_links_route_enum_setting/migration.sql b/prisma/migrations/20240207152849_add_links_route_enum_setting/migration.sql new file mode 100644 index 0000000..646c78c --- /dev/null +++ b/prisma/migrations/20240207152849_add_links_route_enum_setting/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "LinksRouteTo" AS ENUM ('ORIGINAL', 'PDF', 'READABLE', 'SCREENSHOT'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "linksRouteTo" "LinksRouteTo" NOT NULL DEFAULT 'ORIGINAL'; diff --git a/prisma/migrations/20240218080348_allow_duplicate_collection_names/migration.sql b/prisma/migrations/20240218080348_allow_duplicate_collection_names/migration.sql new file mode 100644 index 0000000..d73171b --- /dev/null +++ b/prisma/migrations/20240218080348_allow_duplicate_collection_names/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "Collection_name_ownerId_key"; diff --git a/prisma/migrations/20240222050805_collection_order/migration.sql b/prisma/migrations/20240222050805_collection_order/migration.sql new file mode 100644 index 0000000..ae5687f --- /dev/null +++ b/prisma/migrations/20240222050805_collection_order/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "collectionOrder" INTEGER[] DEFAULT ARRAY[]::INTEGER[]; diff --git a/prisma/migrations/20240305045701_add_merge_duplicate_links/migration.sql b/prisma/migrations/20240305045701_add_merge_duplicate_links/migration.sql new file mode 100644 index 0000000..873a36e --- /dev/null +++ b/prisma/migrations/20240305045701_add_merge_duplicate_links/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "preventDuplicateLinks" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3f10539..64c7b86 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,9 +38,12 @@ model User { tags Tag[] pinnedLinks Link[] collectionsJoined UsersAndCollections[] + collectionOrder Int[] @default([]) whitelistedUsers WhitelistedUser[] - apiKeys ApiKey[] + accessTokens AccessToken[] subscriptions Subscription? + linksRouteTo LinksRouteTo @default(ORIGINAL) + preventDuplicateLinks Boolean @default(false) archiveAsScreenshot Boolean @default(true) archiveAsPDF Boolean @default(true) archiveAsWaybackMachine Boolean @default(false) @@ -49,6 +52,13 @@ model User { updatedAt DateTime @default(now()) @updatedAt } +enum LinksRouteTo { + ORIGINAL + PDF + READABLE + SCREENSHOT +} + model WhitelistedUser { id Int @id @default(autoincrement()) username String @default("") @@ -69,19 +79,20 @@ model VerificationToken { } model Collection { - id Int @id @default(autoincrement()) - name String - description String @default("") - color String @default("#0ea5e9") - isPublic Boolean @default(false) - owner User @relation(fields: [ownerId], references: [id]) - ownerId Int - members UsersAndCollections[] - links Link[] - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - - @@unique([name, ownerId]) + id Int @id @default(autoincrement()) + name String + description String @default("") + color String @default("#0ea5e9") + parentId Int? + parent Collection? @relation("SubCollections", fields: [parentId], references: [id]) + subCollections Collection[] @relation("SubCollections") + isPublic Boolean @default(false) + owner User @relation(fields: [ownerId], references: [id]) + ownerId Int + members UsersAndCollections[] + links Link[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt } model UsersAndCollections { @@ -142,16 +153,15 @@ model Subscription { updatedAt DateTime @default(now()) @updatedAt } -model ApiKey { +model AccessToken { id Int @id @default(autoincrement()) - name String + name String user User @relation(fields: [userId], references: [id]) userId Int token String @unique + revoked Boolean @default(false) expires DateTime lastUsedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - - @@unique([token, userId]) } diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index 37597fc..39c0f85 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index e3365c9..4c15855 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 4fa4cd7..5b14ee3 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png index d6e8886..4e19158 100644 Binary files a/public/favicon-16x16.png and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index 130e748..bd43298 100644 Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico index 82c1669..a07fd23 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/logo_maskable.png b/public/logo_maskable.png new file mode 100644 index 0000000..307583a Binary files /dev/null and b/public/logo_maskable.png differ diff --git a/public/screenshots/screenshot1.png b/public/screenshots/screenshot1.png new file mode 100644 index 0000000..6c2b982 Binary files /dev/null and b/public/screenshots/screenshot1.png differ diff --git a/public/screenshots/screenshot2.png b/public/screenshots/screenshot2.png new file mode 100644 index 0000000..c9326d3 Binary files /dev/null and b/public/screenshots/screenshot2.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest index a3a38f5..f682207 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1 +1,49 @@ -{"name":"Linkwarden","short_name":"Linkwarden","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "id": "/dashboard", + "name":"Linkwarden", + "short_name":"Linkwarden", + "icons":[ + { + "src":"/android-chrome-192x192.png", + "sizes":"192x192", + "type":"image/png" + }, + { + "src":"/android-chrome-512x512.png", + "sizes":"512x512", + "type":"image/png" + }, + { + "src": "/logo_maskable.png", + "sizes": "196x196", + "type": "image/png", + "purpose": "maskable" + } + ], + "share_target": { + "action": "/api/v1/links/", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "url": "link" + } + }, + "screenshots": [ + { + "src": "/screenshots/screenshot1.png", + "type": "image/png", + "sizes": "386x731" + }, + { + "src": "/screenshots/screenshot2.png", + "type": "image/png", + "sizes": "1361x861", + "form_factor": "wide" + } + ], + "theme_color":"#000000", + "background_color":"#000000", + "display":"standalone", + "orientation": "portrait", + "start_url": "/dashboard" +} \ No newline at end of file diff --git a/scripts/worker.ts b/scripts/worker.ts index de025ea..5cddcf1 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -1,4 +1,4 @@ -import 'dotenv/config'; +import "dotenv/config"; import { Collection, Link, User } from "@prisma/client"; import { prisma } from "../lib/api/db"; import archiveHandler from "../lib/api/archiveHandler"; diff --git a/store/collections.ts b/store/collections.ts index 5c78b52..466b652 100644 --- a/store/collections.ts +++ b/store/collections.ts @@ -78,7 +78,11 @@ const useCollectionStore = create()((set) => ({ if (response.ok) { set((state) => ({ - collections: state.collections.filter((e) => e.id !== collectionId), + collections: state.collections.filter( + (collection) => + collection.id !== collectionId && + collection.parentId !== collectionId + ), })); useTagStore.getState().setTags(); } diff --git a/store/links.ts b/store/links.ts index ab74b03..408a3ee 100644 --- a/store/links.ts +++ b/store/links.ts @@ -10,10 +10,12 @@ type ResponseObject = { type LinkStore = { links: LinkIncludingShortenedCollectionAndTags[]; + selectedLinks: LinkIncludingShortenedCollectionAndTags[]; setLinks: ( data: LinkIncludingShortenedCollectionAndTags[], isInitialCall: boolean ) => void; + setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void; addLink: ( body: LinkIncludingShortenedCollectionAndTags ) => Promise; @@ -21,12 +23,22 @@ type LinkStore = { updateLink: ( link: LinkIncludingShortenedCollectionAndTags ) => Promise; + updateLinks: ( + links: LinkIncludingShortenedCollectionAndTags[], + removePreviousTags: boolean, + newData: Pick< + LinkIncludingShortenedCollectionAndTags, + "tags" | "collectionId" + > + ) => Promise; removeLink: (linkId: number) => Promise; + deleteLinksById: (linkIds: number[]) => Promise; resetLinks: () => void; }; const useLinkStore = create()((set) => ({ links: [], + selectedLinks: [], setLinks: async (data, isInitialCall) => { isInitialCall && set(() => ({ @@ -45,6 +57,7 @@ const useLinkStore = create()((set) => ({ ), })); }, + setSelectedLinks: (links) => set({ selectedLinks: links }), addLink: async (body) => { const response = await fetch("/api/v1/links", { body: JSON.stringify(body), @@ -122,6 +135,41 @@ const useLinkStore = create()((set) => ({ return { ok: response.ok, data: data.response }; }, + updateLinks: async (links, removePreviousTags, newData) => { + const response = await fetch("/api/v1/links", { + body: JSON.stringify({ links, removePreviousTags, newData }), + headers: { + "Content-Type": "application/json", + }, + method: "PUT", + }); + + const data = await response.json(); + + if (response.ok) { + set((state) => ({ + links: state.links.map((e) => + links.some((link) => link.id === e.id) + ? { + ...e, + collectionId: newData.collectionId ?? e.collectionId, + collection: { + ...e.collection, + id: newData.collectionId ?? e.collection.id, + }, + tags: removePreviousTags + ? [...(newData.tags ?? [])] + : [...e.tags, ...(newData.tags ?? [])], + } + : e + ), + })); + useTagStore.getState().setTags(); + useCollectionStore.getState().setCollections(); + } + + return { ok: response.ok, data: data.response }; + }, removeLink: async (linkId) => { const response = await fetch(`/api/v1/links/${linkId}`, { headers: { @@ -142,6 +190,27 @@ const useLinkStore = create()((set) => ({ return { ok: response.ok, data: data.response }; }, + deleteLinksById: async (linkIds: number[]) => { + const response = await fetch("/api/v1/links", { + body: JSON.stringify({ linkIds }), + headers: { + "Content-Type": "application/json", + }, + method: "DELETE", + }); + + const data = await response.json(); + + if (response.ok) { + set((state) => ({ + links: state.links.filter((e) => !linkIds.includes(e.id as number)), + })); + useTagStore.getState().setTags(); + useCollectionStore.getState().setCollections(); + } + + return { ok: response.ok, data: data.response }; + }, resetLinks: () => set({ links: [] }), })); diff --git a/store/localSettings.ts b/store/localSettings.ts index e38bae8..6c79d6b 100644 --- a/store/localSettings.ts +++ b/store/localSettings.ts @@ -1,5 +1,4 @@ import { create } from "zustand"; -import { ViewMode } from "@/types/global"; type LocalSettings = { theme?: string; diff --git a/store/tokens.ts b/store/tokens.ts new file mode 100644 index 0000000..eff1100 --- /dev/null +++ b/store/tokens.ts @@ -0,0 +1,56 @@ +import { AccessToken } from "@prisma/client"; +import { create } from "zustand"; + +// Token store + +type ResponseObject = { + ok: boolean; + data: object | string; +}; + +type TokenStore = { + tokens: Partial[]; + setTokens: (data: Partial[]) => void; + addToken: (body: Partial[]) => Promise; + revokeToken: (tokenId: number) => Promise; +}; + +const useTokenStore = create((set) => ({ + tokens: [], + setTokens: async (data) => { + set(() => ({ + tokens: data, + })); + }, + addToken: async (body) => { + const response = await fetch("/api/v1/tokens", { + body: JSON.stringify(body), + method: "POST", + }); + + const data = await response.json(); + + if (response.ok) + set((state) => ({ + tokens: [...state.tokens, data.response.token], + })); + + return { ok: response.ok, data: data.response }; + }, + revokeToken: async (tokenId) => { + const response = await fetch(`/api/v1/tokens/${tokenId}`, { + method: "DELETE", + }); + + const data = await response.json(); + + if (response.ok) + set((state) => ({ + tokens: state.tokens.filter((token) => token.id !== tokenId), + })); + + return { ok: response.ok, data: data.response }; + }, +})); + +export default useTokenStore; diff --git a/styles/globals.css b/styles/globals.css index 6b1c155..256106b 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -27,11 +27,6 @@ color: var(--selection-color); } -html, -body { - scroll-behavior: smooth; -} - /* Hide scrollbar */ .hide-scrollbar::-webkit-scrollbar { display: none; diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index 58d74c5..a0295c9 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -36,6 +36,16 @@ declare global { NEXT_PUBLIC_TRIAL_PERIOD_DAYS?: string; BASE_URL?: string; + // Proxy settings + PROXY?: string; + PROXY_USERNAME?: string; + PROXY_PASSWORD?: string; + PROXY_BYPASS?: string; + + // PDF archive settings + PDF_MARGIN_TOP?: string; + PDF_MARGIN_BOTTOM?: string; + // // SSO Providers // diff --git a/types/global.ts b/types/global.ts index 8b3efcf..e77454d 100644 --- a/types/global.ts +++ b/types/global.ts @@ -33,6 +33,7 @@ export interface CollectionIncludingMembersAndLinkCount id?: number; ownerId?: number; createdAt?: string; + updatedAt?: string; _count?: { links: number }; members: Member[]; } @@ -134,3 +135,11 @@ export enum LinkType { pdf, image, } + +export enum TokenExpiry { + sevenDays, + oneMonth, + twoMonths, + threeMonths, + never, +} diff --git a/types/himalaya.d.ts b/types/himalaya.d.ts new file mode 100644 index 0000000..e2bd5e0 --- /dev/null +++ b/types/himalaya.d.ts @@ -0,0 +1,22 @@ +declare module "himalaya" { + export interface Attribute { + key: string; + value: string; + } + + export interface TextNode { + type: "text"; + content: string; + } + + export type Node = TextNode | Element; + + export interface Element { + type: "element"; + tagName: string; + attributes: Attribute[]; + children: Node[]; + } + + export function parse(html: string): Node[]; +} diff --git a/yarn.lock b/yarn.lock index 3ca11e1..98b7692 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,15 @@ resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== +"@atlaskit/tree@^8.8.7": + version "8.8.7" + resolved "https://registry.yarnpkg.com/@atlaskit/tree/-/tree-8.8.7.tgz#f895137b063f676a490abb0b5deb939a96f51fd7" + integrity sha512-ftbFCzZoa5tZh35EdwMEP9lPuBfw19vtB1CcBmDDMP0AnyEXLjUVfVo8kIls6oI4wivYfIWkZgrUlgN+Jk1b0Q== + dependencies: + "@babel/runtime" "^7.0.0" + css-box-model "^1.2.0" + react-beautiful-dnd-next "11.0.5" + "@auth/core@0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.9.0.tgz#7a5d66eea0bc059cef072734698547ae2a0c86a6" @@ -614,6 +623,21 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/runtime-corejs2@^7.4.5": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.24.0.tgz#23c12d76ac8a7a0ec223c4b0c3b937f9c203fa33" + integrity sha512-RZVGq1it0GA1K8rb+z7v7NzecP6VYCMedN7yHsCCIQUMmRXFCPJD8GISdf6uIGj7NDDihg7ieQEzpdpQbUL75Q== + dependencies: + core-js "^2.6.12" + regenerator-runtime "^0.14.0" + +"@babel/runtime@^7.0.0": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.0.tgz#584c450063ffda59697021430cb47101b085951e" + integrity sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": version "7.21.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200" @@ -621,6 +645,20 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.13.10": + version "7.23.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650" + integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2": + version "7.23.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7" + integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@^7.21.0": version "7.23.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d" @@ -1268,6 +1306,148 @@ resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.0.tgz#4ccf7f344eaeee08ca1e4a1bb2dc14e36ff1d5ec" integrity sha512-HqaFsnPmZOdMWkPq6tT2eTVTQyaAXEDdKszcZ4yc7DGMBIYRP6j/zAJTtZUG9SsMV8FaucdL5vRyxY/p5Ni28g== +"@radix-ui/primitive@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd" + integrity sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-compose-refs@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" + integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-context@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" + integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-dialog@^1.0.4": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300" + integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-controllable-state" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + +"@radix-ui/react-dismissable-layer@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4" + integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-escape-keydown" "1.0.3" + +"@radix-ui/react-focus-guards@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad" + integrity sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-focus-scope@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525" + integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + +"@radix-ui/react-id@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0" + integrity sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-layout-effect" "1.0.1" + +"@radix-ui/react-portal@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15" + integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-primitive" "1.0.3" + +"@radix-ui/react-presence@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba" + integrity sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-use-layout-effect" "1.0.1" + +"@radix-ui/react-primitive@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0" + integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-slot" "1.0.2" + +"@radix-ui/react-slot@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" + integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-compose-refs" "1.0.1" + +"@radix-ui/react-use-callback-ref@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a" + integrity sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/react-use-controllable-state@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286" + integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-callback-ref" "1.0.1" + +"@radix-ui/react-use-escape-keydown@1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755" + integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/react-use-callback-ref" "1.0.1" + +"@radix-ui/react-use-layout-effect@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399" + integrity sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@rushstack/eslint-patch@^1.1.3": version "1.2.0" resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728" @@ -1776,6 +1956,14 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494" + integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + "@types/jsdom@^21.1.3": version "21.1.3" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.3.tgz#a88c5dc65703e1b10b2a7839c12db49662b43ff0" @@ -1844,6 +2032,16 @@ dependencies: "@types/react" "*" +"@types/react-redux@^7.1.20": + version "7.1.33" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.33.tgz#53c5564f03f1ded90904e3c90f77e4bd4dc20b15" + integrity sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + "@types/react-transition-group@^4.4.0": version "4.4.5" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" @@ -1964,6 +2162,13 @@ agent-base@6: dependencies: debug "4" +agent-base@^7.0.2: + version "7.1.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434" + integrity sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg== + dependencies: + debug "^4.3.4" + ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -2044,6 +2249,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.1.1: + version "1.2.3" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.3.tgz#14aeb7fb692bbb72d69bebfa47279c1fd725e954" + integrity sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ== + dependencies: + tslib "^2.0.0" + aria-query@^5.1.3: version "5.1.3" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" @@ -2445,6 +2657,11 @@ cookie@0.5.0, cookie@^0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +core-js@^2.6.12: + version "2.6.12" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" + integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== + core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -2480,6 +2697,13 @@ crypto-js@^4.2.0: resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== +css-box-model@^1.1.2, css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== + dependencies: + tiny-invariant "^1.0.6" + css-selector-tokenizer@^0.8: version "0.8.0" resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz#88267ef6238e64f2215ea2764b3e2cf498b845dd" @@ -2645,6 +2869,11 @@ detect-libc@^2.0.0: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + dezalgo@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" @@ -3296,6 +3525,11 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3: has "^1.0.3" has-symbols "^1.0.3" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-pixels@^3.3.2: version "3.3.3" resolved "https://registry.yarnpkg.com/get-pixels/-/get-pixels-3.3.3.tgz#71e2dfd4befb810b5478a61c6354800976ce01c7" @@ -3547,7 +3781,12 @@ hexoid@^1.0.0: resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== -hoist-non-react-statics@^3.3.1: +himalaya@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/himalaya/-/himalaya-1.1.0.tgz#31724ae9d35714cd7c6f4be94888953f3604606a" + integrity sha512-LLase1dHCRMel68/HZTFft0N0wti0epHr3nNY7ynpLbyZpmrKMQ8YIpiOV77TM97cNpC8Wb2n6f66IRggwdWPw== + +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -3669,11 +3908,26 @@ internal-slot@^1.0.3, internal-slot@^1.0.4: has "^1.0.3" side-channel "^1.0.4" +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + iota-array@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/iota-array/-/iota-array-1.0.0.tgz#81ef57fe5d05814cd58c2483632a99c30a0e8087" integrity sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA== +ip-address@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" + integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g== + dependencies: + jsbn "1.1.0" + sprintf-js "^1.1.3" + is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -3938,6 +4192,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsbn@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -4083,7 +4342,7 @@ lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -loose-envify@^1.1.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -4114,6 +4373,11 @@ make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +memoize-one@^5.0.4: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" @@ -4296,7 +4560,7 @@ node-bitmap@0.0.1: resolved "https://registry.yarnpkg.com/node-bitmap/-/node-bitmap-0.0.1.tgz#180eac7003e0c707618ef31368f62f84b2a69091" integrity sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA== -node-fetch@^2.6.1: +node-fetch@^2.6.1, node-fetch@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -4786,7 +5050,7 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== -prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.8.1: +prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -4837,6 +5101,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +raf-schd@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a" + integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ== + raw-body@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c" @@ -4847,6 +5116,20 @@ raw-body@2.4.1: iconv-lite "0.4.24" unpipe "1.0.0" +react-beautiful-dnd-next@11.0.5: + version "11.0.5" + resolved "https://registry.yarnpkg.com/react-beautiful-dnd-next/-/react-beautiful-dnd-next-11.0.5.tgz#41e693733bbdeb6269b9e4b923a36de2e99ed761" + integrity sha512-kM5Mob41HkA3ShS9uXqeMkW51L5bVsfttxfrwwHucu7I6SdnRKCyN78t6QiLH/UJQQ8T4ukI6NeQAQQpGwolkg== + dependencies: + "@babel/runtime-corejs2" "^7.4.5" + css-box-model "^1.1.2" + memoize-one "^5.0.4" + raf-schd "^4.0.0" + react-redux "^7.0.3" + redux "^4.0.1" + tiny-invariant "^1.0.4" + use-memo-one "^1.1.0" + react-colorful@^5.6.1: version "5.6.1" resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" @@ -4877,6 +5160,42 @@ react-is@^16.13.1, react-is@^16.7.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + +react-redux@^7.0.3: + version "7.2.9" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d" + integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/react-redux" "^7.1.20" + hoist-non-react-statics "^3.3.2" + loose-envify "^1.4.0" + prop-types "^15.7.2" + react-is "^17.0.2" + +react-remove-scroll-bar@^2.3.3: + version "2.3.4" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9" + integrity sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A== + dependencies: + react-style-singleton "^2.2.1" + tslib "^2.0.0" + +react-remove-scroll@2.5.5: + version "2.5.5" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77" + integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw== + dependencies: + react-remove-scroll-bar "^2.3.3" + react-style-singleton "^2.2.1" + tslib "^2.1.0" + use-callback-ref "^1.3.0" + use-sidecar "^1.1.2" + react-select@^5.7.4: version "5.7.4" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.4.tgz#d8cad96e7bc9d6c8e2709bdda8f4363c5dd7ea7d" @@ -4892,6 +5211,15 @@ react-select@^5.7.4: react-transition-group "^4.3.0" use-isomorphic-layout-effect "^1.1.2" +react-style-singleton@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" + integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g== + dependencies: + get-nonce "^1.0.0" + invariant "^2.2.4" + tslib "^2.0.0" + react-transition-group@^4.3.0: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" @@ -4939,6 +5267,13 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +redux@^4.0.0, redux@^4.0.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" + integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w== + dependencies: + "@babel/runtime" "^7.9.2" + regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.3: version "0.13.11" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" @@ -5156,6 +5491,28 @@ slash@^4.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +socks-proxy-agent@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz#5acbd7be7baf18c46a3f293a840109a430a640ad" + integrity sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g== + dependencies: + agent-base "^7.0.2" + debug "^4.3.4" + socks "^2.7.1" + +socks@^2.7.1: + version "2.7.3" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.3.tgz#7d8a75d7ce845c0a96f710917174dba0d543a785" + integrity sha512-vfuYK48HXCTFD03G/1/zkIls3Ebr2YNa4qU9gHDZdblHLiqhJrJGkY3+0Nx0JpN9qBhJbVObc1CNciT1bIZJxw== + dependencies: + ip-address "^9.0.5" + smart-buffer "^4.2.0" + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -5171,6 +5528,11 @@ spawn-command@0.0.2: resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e" integrity sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ== +sprintf-js@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + sshpk@^1.7.0: version "1.17.0" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5" @@ -5435,6 +5797,16 @@ tiny-glob@^0.2.9: globalyzer "0.1.0" globrex "^0.1.2" +tiny-invariant@^1.0.4: + version "1.3.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" + integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== + +tiny-invariant@^1.0.6: + version "1.3.1" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642" + integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== + tinycolor2@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" @@ -5546,7 +5918,7 @@ tslib@^1.11.1, tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.1.0: +tslib@^2.0.0, tslib@^2.1.0: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== @@ -5659,11 +6031,31 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" +use-callback-ref@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.1.tgz#9be64c3902cbd72b07fe55e56408ae3a26036fd0" + integrity sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ== + dependencies: + tslib "^2.0.0" + use-isomorphic-layout-effect@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== +use-memo-one@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" + integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== + +use-sidecar@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2" + integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + use-sync-external-store@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" @@ -5696,6 +6088,13 @@ v8-compile-cache-lib@^3.0.1: resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== +vaul@^0.8.8: + version "0.8.8" + resolved "https://registry.yarnpkg.com/vaul/-/vaul-0.8.8.tgz#c5edc041825fdeaddf0a89e326abcc7ac7449a2d" + integrity sha512-Z9K2b90M/LtY/sRyM1yfA8Y4mHC/5WIqhO2u7Byr49r5LQXkLGdVXiehsnjtws9CL+DyknwTuRMJXlCOHTqg/g== + dependencies: + "@radix-ui/react-dialog" "^1.0.4" + verror@1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"