Refresh Preserved Formats
diff --git a/components/ModalContent/RevokeTokenModal.tsx b/components/ModalContent/RevokeTokenModal.tsx
new file mode 100644
index 00000000..9aef2d92
--- /dev/null
+++ b/components/ModalContent/RevokeTokenModal.tsx
@@ -0,0 +1,62 @@
+import React, { useEffect, useState } from "react";
+import useLinkStore from "@/store/links";
+import toast from "react-hot-toast";
+import Modal from "../Modal";
+import { useRouter } from "next/router";
+import { AccessToken } from "@prisma/client";
+import useTokenStore from "@/store/tokens";
+
+type Props = {
+ onClose: Function;
+ activeToken: AccessToken;
+};
+
+export default function DeleteTokenModal({ onClose, activeToken }: Props) {
+ const [token, setToken] = useState(activeToken);
+
+ const { revokeToken } = useTokenStore();
+ const [submitLoader, setSubmitLoader] = useState(false);
+
+ const router = useRouter();
+
+ useEffect(() => {
+ setToken(activeToken);
+ }, []);
+
+ const deleteLink = async () => {
+ console.log(token);
+ const load = toast.loading("Deleting...");
+
+ const response = await revokeToken(token.id as number);
+
+ toast.dismiss(load);
+
+ response.ok && toast.success(`Token Revoked.`);
+
+ onClose();
+ };
+
+ return (
+
+ Revoke Token
+
+
+
+
+
+ Are you sure you want to revoke this Access Token? Any apps or
+ services using this token will no longer be able to access Linkwarden
+ using it.
+
+
+
+
+
+ );
+}
diff --git a/components/Navbar.tsx b/components/Navbar.tsx
index fb3678ea..eee02081 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 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 ec25a6e1..340fa76c 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 75c0686b..66a8d233 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 CollectionSelection from "@/components/CollectionSelection";
export default function Sidebar({ className }: { className?: string }) {
const [tagDisclosure, setTagDisclosure] = useState(() => {
@@ -44,7 +45,7 @@ export default function Sidebar({ className }: { className?: string }) {
return (
diff --git a/lib/api/archiveHandler.ts b/lib/api/archiveHandler.ts
index a7785dba..89eb4a0f 100644
--- a/lib/api/archiveHandler.ts
+++ b/lib/api/archiveHandler.ts
@@ -32,7 +32,11 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
}
const browser = await chromium.launch(browserOptions);
- const context = await browser.newContext(devices["Desktop Chrome"]);
+ 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) => {
diff --git a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts
index 03870aad..98b1e175 100644
--- a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts
+++ b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts
@@ -37,6 +37,8 @@ export default async function deleteCollection(
}
const deletedCollection = await prisma.$transaction(async () => {
+ await deleteSubCollections(collectionId);
+
await prisma.usersAndCollections.deleteMany({
where: {
collection: {
@@ -53,7 +55,7 @@ export default async function deleteCollection(
},
});
- removeFolder({ filePath: `archives/${collectionId}` });
+ await removeFolder({ filePath: `archives/${collectionId}` });
return await prisma.collection.delete({
where: {
@@ -64,3 +66,35 @@ 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}` });
+ }
+}
diff --git a/lib/api/controllers/collections/collectionId/getCollectionById.ts b/lib/api/controllers/collections/collectionId/getCollectionById.ts
new file mode 100644
index 00000000..46478b83
--- /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 f2570219..ca9b7895 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,26 @@ export default async function updateCollection(
if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 };
+ if (data.parentId) {
+ const findParentCollection = await prisma.collection.findUnique({
+ where: {
+ id: data.parentId,
+ },
+ select: {
+ ownerId: true,
+ },
+ });
+
+ if (
+ findParentCollection?.ownerId !== userId ||
+ typeof data.parentId !== "number"
+ )
+ return {
+ response: "You are not authorized to create a sub-collection here.",
+ status: 403,
+ };
+ }
+
const updatedCollection = await prisma.$transaction(async () => {
await prisma.usersAndCollections.deleteMany({
where: {
@@ -32,12 +51,18 @@ 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
+ ? {
+ connect: {
+ id: data.parentId,
+ },
+ }
+ : undefined,
members: {
create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } },
diff --git a/lib/api/controllers/collections/postCollection.ts b/lib/api/controllers/collections/postCollection.ts
index 86ca3793..245f6423 100644
--- a/lib/api/controllers/collections/postCollection.ts
+++ b/lib/api/controllers/collections/postCollection.ts
@@ -12,6 +12,26 @@ export default async function postCollection(
status: 400,
};
+ if (collection.parentId) {
+ const findParentCollection = await prisma.collection.findUnique({
+ where: {
+ id: collection.parentId,
+ },
+ select: {
+ ownerId: true,
+ },
+ });
+
+ if (
+ findParentCollection?.ownerId !== userId ||
+ typeof collection.parentId !== "number"
+ )
+ return {
+ response: "You are not authorized to create a sub-collection here.",
+ status: 403,
+ };
+ }
+
const findCollection = await prisma.user.findUnique({
where: {
id: userId,
@@ -28,7 +48,10 @@ export default async function postCollection(
const checkIfCollectionExists = findCollection?.collections[0];
if (checkIfCollectionExists)
- return { response: "Collection already exists.", status: 400 };
+ return {
+ response: "Oops! There's already a Collection with that name.",
+ status: 400,
+ };
const newCollection = await prisma.collection.create({
data: {
@@ -40,6 +63,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: {
diff --git a/lib/api/controllers/links/bulk/deleteLinksById.ts b/lib/api/controllers/links/bulk/deleteLinksById.ts
new file mode 100644
index 00000000..466db983
--- /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 00000000..a214c300
--- /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 90adba40..db68ee7d 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 7f7fb2eb..62b2945c 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";
@@ -16,6 +16,10 @@ export default async function updateLinkById(
};
const collectionIsAccessible = await getPermission({ userId, linkId });
+ const targetCollectionIsAccessible = await getPermission({
+ userId,
+ collectionId: data.collection.id,
+ });
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
@@ -25,9 +29,61 @@ export default async function updateLinkById(
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;
+ 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 };
+ }
+
// Makes sure collection members (non-owners) cannot move a link to/from a collection.
if (unauthorizedSwitchCollection)
return {
diff --git a/lib/api/controllers/migration/importFromHTMLFile.ts b/lib/api/controllers/migration/importFromHTMLFile.ts
index a2fae271..43986007 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/tokens/getTokens.ts b/lib/api/controllers/tokens/getTokens.ts
new file mode 100644
index 00000000..a5db351a
--- /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 00000000..f88030d8
--- /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 00000000..ea17f1f6
--- /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 285e44e5..6bb74806 100644
--- a/lib/api/controllers/users/userId/updateUserById.ts
+++ b/lib/api/controllers/users/userId/updateUserById.ts
@@ -186,6 +186,7 @@ export default async function updateUserById(
archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
+ linksRouteTo: data.linksRouteTo,
password:
data.newPassword && data.newPassword !== ""
? newHashedPassword
diff --git a/lib/api/getPermission.ts b/lib/api/getPermission.ts
index 61dc5c50..93dd04ce 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 00000000..1c1abfcd
--- /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 db59e6e8..847bdaf5 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 00000000..47c1888e
--- /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 00000000..7d139c55
--- /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 395de00c..0da5504e 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/package.json b/package.json
index 76edd7e1..9338ac74 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"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",
@@ -61,6 +62,7 @@
"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 60c15c38..b9659411 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) ? (
+
+ ) : 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 && (
+
+ handleSelectAll()}
+ checked={
+ selectedLinks.length === links.length && links.length > 0
+ }
+ />
+ {selectedLinks.length > 0 ? (
+
+ {selectedLinks.length}{" "}
+ {selectedLinks.length === 1 ? "link" : "links"} selected
+
+ ) : (
+ Nothing selected
+ )}
+
+ )}
+
+
+
+
+
+ )}
+
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
e.collection.id === activeCollection?.id
)}
@@ -246,28 +404,48 @@ export default function Index() {
)}
- {activeCollection ? (
+ {activeCollection && (
<>
- {editCollectionModal ? (
+ {editCollectionModal && (
setEditCollectionModal(false)}
activeCollection={activeCollection}
/>
- ) : undefined}
- {editCollectionSharingModal ? (
+ )}
+ {editCollectionSharingModal && (
setEditCollectionSharingModal(false)}
activeCollection={activeCollection}
/>
- ) : undefined}
- {deleteCollectionModal ? (
+ )}
+ {newCollectionModal && (
+ setNewCollectionModal(false)}
+ parent={activeCollection}
+ />
+ )}
+ {deleteCollectionModal && (
setDeleteCollectionModal(false)}
activeCollection={activeCollection}
/>
- ) : undefined}
+ )}
+ {bulkDeleteLinksModal && (
+ {
+ setBulkDeleteLinksModal(false);
+ }}
+ />
+ )}
+ {bulkEditLinksModal && (
+ {
+ setBulkEditLinksModal(false);
+ }}
+ />
+ )}
>
- ) : undefined}
+ )}
);
}
diff --git a/pages/collections/index.tsx b/pages/collections/index.tsx
index 923869f7..f5ed526c 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 18973c7d..ec5dc78e 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 && (
+
+ handleSelectAll()}
+ checked={
+ selectedLinks.length === links.length && links.length > 0
+ }
+ />
+ {selectedLinks.length > 0 ? (
+
+ {selectedLinks.length}{" "}
+ {selectedLinks.length === 1 ? "link" : "links"} selected
+
+ ) : (
+ Nothing selected
+ )}
+
+ )}
+
+
+
+
+
+ )}
+
{links[0] ? (
-
+
) : (
)}
+ {bulkDeleteLinksModal && (
+
{
+ setBulkDeleteLinksModal(false);
+ }}
+ />
+ )}
+ {bulkEditLinksModal && (
+ {
+ setBulkEditLinksModal(false);
+ }}
+ />
+ )}
);
}
diff --git a/pages/links/pinned.tsx b/pages/links/pinned.tsx
index c6b5ee06..4e8cc62e 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 && (
+
+ handleSelectAll()}
+ checked={
+ selectedLinks.length === links.length && links.length > 0
+ }
+ />
+ {selectedLinks.length > 0 ? (
+
+ {selectedLinks.length}{" "}
+ {selectedLinks.length === 1 ? "link" : "links"} selected
+
+ ) : (
+ Nothing selected
+ )}
+
+ )}
+
+
+
+
+
+ )}
+
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
-
+
) : (
)}
+ {bulkDeleteLinksModal && (
+ {
+ setBulkDeleteLinksModal(false);
+ }}
+ />
+ )}
+ {bulkEditLinksModal && (
+ {
+ setBulkEditLinksModal(false);
+ }}
+ />
+ )}
);
}
diff --git a/pages/login.tsx b/pages/login.tsx
index 796da864..5b528b98 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 32dbf969..ec686bfd 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 00000000..1204ce8a
--- /dev/null
+++ b/pages/settings/access-tokens.tsx
@@ -0,0 +1,107 @@
+import SettingsLayout from "@/layouts/SettingsLayout";
+import React, { useEffect, useState } from "react";
+import NewTokenModal from "@/components/ModalContent/NewTokenModal";
+import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
+import { AccessToken } from "@prisma/client";
+import useTokenStore from "@/store/tokens";
+
+export default function AccessTokens() {
+ const [newTokenModal, setNewTokenModal] = useState(false);
+ const [revokeTokenModal, setRevokeTokenModal] = useState(false);
+ const [selectedToken, setSelectedToken] = useState(null);
+
+ const openRevokeModal = (token: AccessToken) => {
+ setSelectedToken(token);
+ setRevokeTokenModal(true);
+ };
+
+ const { setTokens, tokens } = useTokenStore();
+
+ useEffect(() => {
+ fetch("/api/v1/tokens")
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.response) setTokens(data.response as AccessToken[]);
+ });
+ }, []);
+
+ return (
+
+ Access Tokens
+
+
+
+
+
+ Access Tokens can be used to access Linkwarden from other apps and
+ services without giving away your Username and Password.
+
+
+
+
+ {tokens.length > 0 ? (
+ <>
+
+
+
+ {/* head */}
+
+
+ |
+ Name |
+ Created |
+ Expires |
+ |
+
+
+
+ {tokens.map((token, i) => (
+
+
+ | {i + 1} |
+ {token.name} |
+
+ {new Date(token.createdAt || "").toLocaleDateString()}
+ |
+
+ {new Date(token.expires || "").toLocaleDateString()}
+ |
+
+
+ |
+
+
+ ))}
+
+
+ >
+ ) : undefined}
+
+
+ {newTokenModal ? (
+ setNewTokenModal(false)} />
+ ) : undefined}
+ {revokeTokenModal && selectedToken && (
+ {
+ setRevokeTokenModal(false);
+ setSelectedToken(null);
+ }}
+ activeToken={selectedToken}
+ />
+ )}
+
+ );
+}
diff --git a/pages/settings/account.tsx b/pages/settings/account.tsx
index dcd0a87a..8d3134ec 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 dc4bb9a9..00000000
--- 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 385225a2..00000000
--- 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 f6646380..00000000
--- 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 75f20cd2..ee8af3bc 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 00000000..2456e92c
--- /dev/null
+++ b/pages/settings/preference.tsx
@@ -0,0 +1,225 @@
+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 [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,
+ });
+ }, [
+ account,
+ archiveAsScreenshot,
+ archiveAsPDF,
+ archiveAsWaybackMachine,
+ linksRouteTo,
+ ]);
+
+ 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);
+ }
+ }, [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 a4cbb69b..8924a3cf 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 && (
+
+ handleSelectAll()}
+ checked={
+ selectedLinks.length === links.length && links.length > 0
+ }
+ />
+ {selectedLinks.length > 0 ? (
+
+ {selectedLinks.length}{" "}
+ {selectedLinks.length === 1 ? "link" : "links"} selected
+
+ ) : (
+ Nothing selected
+ )}
+
+ )}
+
+
+
+
+
+ )}
e.tags.some((e) => e.id === Number(router.query.id))
)}
/>
+ {bulkDeleteLinksModal && (
+
{
+ setBulkDeleteLinksModal(false);
+ }}
+ />
+ )}
+ {bulkEditLinksModal && (
+ {
+ setBulkEditLinksModal(false);
+ }}
+ />
+ )}
);
}
diff --git a/prisma/migrations/20240113051701_make_key_names_unique/migration.sql b/prisma/migrations/20240113051701_make_key_names_unique/migration.sql
new file mode 100644
index 00000000..55efb95c
--- /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 00000000..d3999b62
--- /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 00000000..b9802a07
--- /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 00000000..4a570db4
--- /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 00000000..94799569
--- /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 00000000..646c78c1
--- /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/schema.prisma b/prisma/schema.prisma
index 3f105394..036f658f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -39,8 +39,9 @@ model User {
pinnedLinks Link[]
collectionsJoined UsersAndCollections[]
whitelistedUsers WhitelistedUser[]
- apiKeys ApiKey[]
+ accessTokens AccessToken[]
subscriptions Subscription?
+ linksRouteTo LinksRouteTo @default(ORIGINAL)
archiveAsScreenshot Boolean @default(true)
archiveAsPDF Boolean @default(true)
archiveAsWaybackMachine Boolean @default(false)
@@ -49,6 +50,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,17 +77,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
+ 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
@@unique([name, ownerId])
}
@@ -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 37597fc9..39c0f857 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 e3365c90..4c158553 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 4fa4cd72..5b14ee34 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 d6e88864..4e191582 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 130e748c..bd43298f 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 82c1669b..a07fd231 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 00000000..307583a8
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 00000000..6c2b9820
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 00000000..c9326d3b
Binary files /dev/null and b/public/screenshots/screenshot2.png differ
diff --git a/public/site.webmanifest b/public/site.webmanifest
index a3a38f5b..f6822078 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 de025eaa..5cddcf17 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 5c78b528..466b652e 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 ab74b036..408a3eea 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 e38bae8d..6c79d6b4 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 00000000..eff11000
--- /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 6b1c1553..256106b8 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/global.ts b/types/global.ts
index 8b3efcfc..3c8de79b 100644
--- a/types/global.ts
+++ b/types/global.ts
@@ -134,3 +134,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 00000000..e2bd5e0b
--- /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 feb0d5e8..ce0383ca 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -614,7 +614,21 @@
chalk "^2.0.0"
js-tokens "^4.0.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.21.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
+"@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"
+ integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==
+ 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.21.0":
version "7.23.6"
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.6.tgz"
integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==
@@ -1216,6 +1230,148 @@
resolved "https://registry.npmjs.org/@prisma/engines/-/engines-5.1.0.tgz"
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.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz"
@@ -1984,6 +2140,13 @@ argparse@^2.0.1:
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
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.npmjs.org/aria-query/-/aria-query-5.1.3.tgz"
@@ -2585,6 +2748,11 @@ detect-libc@^2.0.0:
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz"
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.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz"
@@ -3241,6 +3409,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.npmjs.org/get-pixels/-/get-pixels-3.3.3.tgz"
@@ -3482,6 +3655,11 @@ hexoid@^1.0.0:
resolved "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz"
integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==
+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.1:
version "3.3.2"
resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz"
@@ -3604,6 +3782,13 @@ 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.npmjs.org/iota-array/-/iota-array-1.0.0.tgz"
@@ -4031,7 +4216,7 @@ lodash@^4.17.21:
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
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.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -4827,6 +5012,25 @@ react-is@^16.13.1, react-is@^16.7.0:
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
+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.npmjs.org/react-select/-/react-select-5.7.4.tgz"
@@ -4842,6 +5046,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.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz"
@@ -5648,11 +5861,26 @@ 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.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
+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.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
@@ -5685,6 +5913,13 @@ v8-compile-cache-lib@^3.0.1:
resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz"
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.npmjs.org/verror/-/verror-1.10.0.tgz"