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.
+
+
+
+
+ Revoke
+
+
+
+ );
+}
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
-
+
-
-
-
-
-
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 ",
@@ -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 (
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
+ )}
+
+ )}
+
+ setBulkEditLinksModal(true)}
+ className="btn btn-sm btn-accent text-white w-fit ml-auto"
+ disabled={
+ selectedLinks.length === 0 ||
+ !(permissions === true || permissions?.canUpdate)
+ }
+ >
+ Edit
+
+ {
+ (document?.activeElement as HTMLElement)?.blur();
+ e.shiftKey
+ ? bulkDeleteLinks()
+ : setBulkDeleteLinksModal(true);
+ }}
+ className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
+ disabled={
+ selectedLinks.length === 0 ||
+ !(permissions === true || permissions?.canDelete)
+ }
+ >
+ Delete
+
+
+
+ )}
+
{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
+ )}
+
+ )}
+
+ setBulkEditLinksModal(true)}
+ className="btn btn-sm btn-accent text-white w-fit ml-auto"
+ disabled={
+ selectedLinks.length === 0 ||
+ !(
+ collectivePermissions === true ||
+ collectivePermissions?.canUpdate
+ )
+ }
+ >
+ Edit
+
+ {
+ (document?.activeElement as HTMLElement)?.blur();
+ e.shiftKey
+ ? bulkDeleteLinks()
+ : setBulkDeleteLinksModal(true);
+ }}
+ className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
+ disabled={
+ selectedLinks.length === 0 ||
+ !(
+ collectivePermissions === true ||
+ collectivePermissions?.canDelete
+ )
+ }
+ >
+ Delete
+
+
+
+ )}
+
{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
+ )}
+
+ )}
+
+ setBulkEditLinksModal(true)}
+ className="btn btn-sm btn-accent text-white w-fit ml-auto"
+ disabled={
+ selectedLinks.length === 0 ||
+ !(
+ collectivePermissions === true ||
+ collectivePermissions?.canUpdate
+ )
+ }
+ >
+ Edit
+
+ {
+ (document?.activeElement as HTMLElement)?.blur();
+ e.shiftKey
+ ? bulkDeleteLinks()
+ : setBulkDeleteLinksModal(true);
+ }}
+ className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
+ disabled={
+ selectedLinks.length === 0 ||
+ !(
+ collectivePermissions === true ||
+ collectivePermissions?.canDelete
+ )
+ }
+ >
+ Delete
+
+
+
+ )}
+
{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.
+
+
+
{
+ setNewTokenModal(true);
+ }}
+ >
+ New Access Token
+
+
+ {tokens.length > 0 ? (
+ <>
+
+
+
+ {/* head */}
+
+
+
+ Name
+ Created
+ Expires
+
+
+
+
+ {tokens.map((token, i) => (
+
+
+ {i + 1}
+ {token.name}
+
+ {new Date(token.createdAt || "").toLocaleDateString()}
+
+
+ {new Date(token.expires || "").toLocaleDateString()}
+
+
+ openRevokeModal(token as AccessToken)}
+ >
+
+
+
+
+
+ ))}
+
+
+ >
+ ) : 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)
+ }
+ />
+
+
+
+
+
+
+
+
+ );
+}
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
+ )}
+
+ )}
+
+ setBulkEditLinksModal(true)}
+ className="btn btn-sm btn-accent text-white w-fit ml-auto"
+ disabled={
+ selectedLinks.length === 0 ||
+ !(
+ collectivePermissions === true ||
+ collectivePermissions?.canUpdate
+ )
+ }
+ >
+ Edit
+
+ {
+ (document?.activeElement as HTMLElement)?.blur();
+ e.shiftKey
+ ? bulkDeleteLinks()
+ : setBulkDeleteLinksModal(true);
+ }}
+ className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
+ disabled={
+ selectedLinks.length === 0 ||
+ !(
+ collectivePermissions === true ||
+ collectivePermissions?.canDelete
+ )
+ }
+ >
+ Delete
+
+
+
+ )}
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"