Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e51fba41e7 | |||
| e8edd1c9a0 | |||
| f30c652676 | |||
| 8cf621bc62 | |||
| 87eb2471ff | |||
| 58b6f7339c | |||
| 5503483502 | |||
| a6d018fb53 | |||
| 3929f32e63 | |||
| c08522386b | |||
| b51a876904 | |||
| 2e2d7baee1 | |||
| 495af0a752 | |||
| 388b9d9184 | |||
| e5fcf18fa4 | |||
| a3d3b353a1 | |||
| 546e216ac9 | |||
| ffc037b854 | |||
| 53a65774f0 | |||
| 5990d4ce2d | |||
| ce2eb8eafb | |||
| bae4cf1d4f | |||
| 4e20d71a41 | |||
| 4a0e75c6e5 | |||
| 9fce74971f | |||
| 3feeecdc1d | |||
| bde7b9aae0 | |||
| bda0dc6c87 | |||
| 7dd254af48 | |||
| a57c3114d8 | |||
| 3969cc5abd | |||
| 252d41886a | |||
| d8bab2eb24 | |||
| 9bfba6037e | |||
| e59ab23b3d | |||
| 01b3b4485e | |||
| 8c76b0d141 | |||
| d2b867c438 | |||
| f26cd31694 | |||
| 8dcd2c67d2 | |||
| 750aa294d0 | |||
| 281b376eac | |||
| 837241186f | |||
| 51cf8172ff | |||
| 9c51a65f31 | |||
| a451e9fa2e | |||
| ba4860a910 | |||
| 84aeac96ce | |||
| ac70c9e29c | |||
| f77ef58396 | |||
| 4442ce8705 | |||
| 4ff7298a3b | |||
| a8be4d8f2f | |||
| f183f122e9 | |||
| 5164f287d4 | |||
| 439c562002 | |||
| cc02ab3615 | |||
| d2e59d48c2 | |||
| dbfdb587b6 | |||
| 7fd9f5b806 | |||
| 69ac3eb01f | |||
| 44272540aa | |||
| 0dda77db1e | |||
| 60aa7b830e | |||
| b6ad2b5900 | |||
| aee1828c15 | |||
| 67bf6b7d75 | |||
| bbc2e4c457 | |||
| 1f28d9d461 | |||
| df1da9f1f8 | |||
| b476b3ccd4 | |||
| ae561ff227 | |||
| d438381ebd | |||
| 72266d1cd5 | |||
| f560422427 | |||
| 7b7b979b20 | |||
| c3c74b8162 | |||
| 0e60dee47d | |||
| c3f72c4be8 | |||
| 79bd95f650 | |||
| 88d73703f8 | |||
| 41df9d0c82 | |||
| 0b2e78332a | |||
| 558ba11db7 | |||
| 155c77cbc4 | |||
| a3c487d074 | |||
| 1cff2db876 | |||
| 2112176d6e | |||
| aef33d859e | |||
| 5128bd44d8 | |||
| 0a77ee90a7 | |||
| e2c6993a6d | |||
| e1c4a8575b | |||
| 0c531760e8 | |||
| 5f468cd95d | |||
| 63597a041f | |||
| e753f1dded | |||
| 8ecedf7cae | |||
| 44daffbae6 | |||
| d5f262200b | |||
| ccd3fcb8c1 | |||
| 059fcecc5f | |||
| 58e2fb22c9 | |||
| 2ace10c058 | |||
| 4b8f4c4179 | |||
| 8f62f4dffb | |||
| 95dc3b31db | |||
| ebdeedc2ec | |||
| 325c41254d | |||
| fda782ec44 | |||
| 080be856cc | |||
| e1ef638f0e | |||
| 582607e726 | |||
| 9eaa106766 | |||
| e0705ece4f | |||
| da0533ac36 | |||
| e3d9912378 | |||
| 26997475fd | |||
| ea31eb47ae | |||
| 193c66123b | |||
| eba9d3c86d | |||
| b51355b406 | |||
| 0a070deebd | |||
| c78aa2da0d | |||
| aef55d65a1 | |||
| efddd55841 | |||
| f7a53d53e2 | |||
| ef08edf1fb | |||
| 39261de45e | |||
| cc915c8a64 | |||
| 7d9cc1f1f0 | |||
| b06cb7c379 | |||
| d5bd095827 | |||
| daed2d82f4 | |||
| 39e022f87b | |||
| 2d0093172a | |||
| 34e0115a0f | |||
| ae3cf104b7 | |||
| 047e156cfb |
+11
@@ -20,6 +20,8 @@ MAX_LINKS_PER_USER=
|
|||||||
ARCHIVE_TAKE_COUNT=
|
ARCHIVE_TAKE_COUNT=
|
||||||
BROWSER_TIMEOUT=
|
BROWSER_TIMEOUT=
|
||||||
IGNORE_UNAUTHORIZED_CA=
|
IGNORE_UNAUTHORIZED_CA=
|
||||||
|
IGNORE_HTTPS_ERRORS=
|
||||||
|
IGNORE_URL_SIZE_LIMIT=
|
||||||
|
|
||||||
# AWS S3 Settings
|
# AWS S3 Settings
|
||||||
SPACES_KEY=
|
SPACES_KEY=
|
||||||
@@ -34,6 +36,15 @@ NEXT_PUBLIC_EMAIL_PROVIDER=
|
|||||||
EMAIL_FROM=
|
EMAIL_FROM=
|
||||||
EMAIL_SERVER=
|
EMAIL_SERVER=
|
||||||
|
|
||||||
|
# Proxy settings
|
||||||
|
PROXY=
|
||||||
|
PROXY_USERNAME=
|
||||||
|
PROXY_PASSWORD=
|
||||||
|
PROXY_BYPASS=
|
||||||
|
|
||||||
|
# PDF archive settings
|
||||||
|
PDF_MARGIN_TOP=
|
||||||
|
PDF_MARGIN_BOTTOM=
|
||||||
|
|
||||||
#
|
#
|
||||||
# SSO Providers
|
# SSO Providers
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
|
||||||
|
|
||||||
|
When you start Linkwarden, there are mainly two components that run:
|
||||||
|
|
||||||
|
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
|
||||||
|
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
|
||||||
|
|
||||||
|
## Main Tech Stack
|
||||||
|
|
||||||
|
- [NextJS](https://github.com/vercel/next.js)
|
||||||
|
- [TypeScript](https://github.com/microsoft/TypeScript)
|
||||||
|
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
|
||||||
|
- [DaisyUI](https://github.com/saadeghi/daisyui)
|
||||||
|
- [Prisma](https://github.com/prisma/prisma)
|
||||||
|
- [Playwright](https://github.com/microsoft/playwright)
|
||||||
|
- [Zustand](https://github.com/pmndrs/zustand)
|
||||||
|
|
||||||
|
## Folder Structure
|
||||||
|
|
||||||
|
Here's a summary of the main files and folders in the project:
|
||||||
|
|
||||||
|
```
|
||||||
|
linkwarden
|
||||||
|
├── components # React components
|
||||||
|
├── hooks # React reusable hooks
|
||||||
|
├── layouts # Layouts for pages
|
||||||
|
├── lib
|
||||||
|
│ ├── api # Server-side functions (controllers, etc.)
|
||||||
|
│ ├── client # Client-side functions
|
||||||
|
│ └── shared # Shared functions between client and server
|
||||||
|
├── pages # Pages and API routes
|
||||||
|
├── prisma # Prisma schema and migrations
|
||||||
|
├── scripts
|
||||||
|
│ ├── migration # Scripts for breaking changes
|
||||||
|
│ └── worker.ts # Background worker for archiving links
|
||||||
|
├── store # Zustand stores
|
||||||
|
├── styles # Styles
|
||||||
|
└── types # TypeScript types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).
|
||||||
@@ -59,7 +59,7 @@ We've forked the old version from the current repository into [this repo](https:
|
|||||||
|
|
||||||
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
|
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
|
||||||
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
|
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
|
||||||
- 📂 Organize links by collection, name, description and multiple tags.
|
- 📂 Organize links by collection, sub-collection, name, description and multiple tags.
|
||||||
- 👥 Collaborate on gathering links in a collection.
|
- 👥 Collaborate on gathering links in a collection.
|
||||||
- 🎛️ Customize the permissions of each member.
|
- 🎛️ Customize the permissions of each member.
|
||||||
- 🌐 Share your collected links and preserved formats with the world.
|
- 🌐 Share your collected links and preserved formats with the world.
|
||||||
@@ -70,6 +70,10 @@ We've forked the old version from the current repository into [this repo](https:
|
|||||||
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
|
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
|
||||||
- ⬇️ Import and export your bookmarks.
|
- ⬇️ Import and export your bookmarks.
|
||||||
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
|
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
|
||||||
|
- 📦 Installable Progressive Web App (PWA).
|
||||||
|
- 🍎 iOS Shortcut to save links to Linkwarden.
|
||||||
|
- 🔑 API keys.
|
||||||
|
- ✅ Bulk actions.
|
||||||
- ✨ And so many more features!
|
- ✨ And so many more features!
|
||||||
|
|
||||||
## Like what we're doing? Give us a Star ⭐
|
## Like what we're doing? Give us a Star ⭐
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
|
|||||||
<div className="w-fit font-semibold">
|
<div className="w-fit font-semibold">
|
||||||
🎉️ See what's new in{" "}
|
🎉️ See what's new in{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://blog.linkwarden.app/releases/v2.4"
|
href="https://blog.linkwarden.app/releases/v2.5"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="underline hover:opacity-50 duration-100"
|
className="underline hover:opacity-50 duration-100"
|
||||||
>
|
>
|
||||||
Linkwarden v2.4
|
Linkwarden v2.5
|
||||||
</Link>
|
</Link>
|
||||||
! 🥳️
|
! 🥳️
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import Tree, {
|
||||||
|
mutateTree,
|
||||||
|
moveItemOnTree,
|
||||||
|
RenderItemParams,
|
||||||
|
TreeItem,
|
||||||
|
TreeData,
|
||||||
|
ItemId,
|
||||||
|
TreeSourcePosition,
|
||||||
|
TreeDestinationPosition,
|
||||||
|
} from "@atlaskit/tree";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import { Collection } from "@prisma/client";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
interface ExtendedTreeItem extends TreeItem {
|
||||||
|
data: Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionListing = () => {
|
||||||
|
const { collections, updateCollection } = useCollectionStore();
|
||||||
|
const { account, updateAccount } = useAccountStore();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const currentPath = router.asPath;
|
||||||
|
|
||||||
|
const initialTree = useMemo(() => {
|
||||||
|
if (collections.length > 0) {
|
||||||
|
return buildTreeFromCollections(
|
||||||
|
collections,
|
||||||
|
router,
|
||||||
|
account.collectionOrder
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [collections, router]);
|
||||||
|
|
||||||
|
const [tree, setTree] = useState(initialTree);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTree(initialTree);
|
||||||
|
}, [initialTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account.username) {
|
||||||
|
if (
|
||||||
|
(!account.collectionOrder || account.collectionOrder.length === 0) &&
|
||||||
|
collections.length > 0
|
||||||
|
)
|
||||||
|
updateAccount({
|
||||||
|
...account,
|
||||||
|
collectionOrder: collections
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
e.parentId === null ||
|
||||||
|
!collections.find((i) => i.id === e.parentId)
|
||||||
|
) // Filter out collections with non-null parentId
|
||||||
|
.map((e) => e.id as number), // Use "as number" to assert that e.id is a number
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
const newCollectionOrder: number[] = [
|
||||||
|
...(account.collectionOrder || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Start with collections that are in both account.collectionOrder and collections
|
||||||
|
const existingCollectionIds = collections.map((c) => c.id as number);
|
||||||
|
const filteredCollectionOrder = account.collectionOrder.filter((id) =>
|
||||||
|
existingCollectionIds.includes(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new collections that are not in account.collectionOrder and meet the specific conditions
|
||||||
|
collections.forEach((collection) => {
|
||||||
|
if (
|
||||||
|
!filteredCollectionOrder.includes(collection.id as number) &&
|
||||||
|
(!collection.parentId || collection.ownerId === account.id)
|
||||||
|
) {
|
||||||
|
filteredCollectionOrder.push(collection.id as number);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if the newCollectionOrder is the same as the old one
|
||||||
|
if (
|
||||||
|
JSON.stringify(newCollectionOrder) !==
|
||||||
|
JSON.stringify(account.collectionOrder)
|
||||||
|
) {
|
||||||
|
updateAccount({
|
||||||
|
...account,
|
||||||
|
collectionOrder: newCollectionOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [collections]);
|
||||||
|
|
||||||
|
const onExpand = (movedCollectionId: ItemId) => {
|
||||||
|
setTree((currentTree) =>
|
||||||
|
mutateTree(currentTree!, movedCollectionId, { isExpanded: true })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCollapse = (movedCollectionId: ItemId) => {
|
||||||
|
setTree((currentTree) =>
|
||||||
|
mutateTree(currentTree as TreeData, movedCollectionId, {
|
||||||
|
isExpanded: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = async (
|
||||||
|
source: TreeSourcePosition,
|
||||||
|
destination: TreeDestinationPosition | undefined
|
||||||
|
) => {
|
||||||
|
if (!destination || !tree) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
source.index === destination.index &&
|
||||||
|
source.parentId === destination.parentId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedCollectionId = Number(
|
||||||
|
tree.items[source.parentId].children[source.index]
|
||||||
|
);
|
||||||
|
|
||||||
|
const movedCollection = collections.find((c) => c.id === movedCollectionId);
|
||||||
|
|
||||||
|
const destinationCollection = collections.find(
|
||||||
|
(c) => c.id === Number(destination.parentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(movedCollection?.ownerId !== account.id &&
|
||||||
|
destination.parentId !== source.parentId) ||
|
||||||
|
(destinationCollection?.ownerId !== account.id &&
|
||||||
|
destination.parentId !== "root")
|
||||||
|
) {
|
||||||
|
return toast.error(
|
||||||
|
"You can't make change to a collection you don't own."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
|
||||||
|
|
||||||
|
const updatedCollectionOrder = [...account.collectionOrder];
|
||||||
|
|
||||||
|
if (source.parentId !== destination.parentId) {
|
||||||
|
await updateCollection({
|
||||||
|
...movedCollection,
|
||||||
|
parentId:
|
||||||
|
destination.parentId && destination.parentId !== "root"
|
||||||
|
? Number(destination.parentId)
|
||||||
|
: destination.parentId === "root"
|
||||||
|
? "root"
|
||||||
|
: null,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
destination.index !== undefined &&
|
||||||
|
destination.parentId === source.parentId &&
|
||||||
|
source.parentId === "root"
|
||||||
|
) {
|
||||||
|
updatedCollectionOrder.includes(movedCollectionId) &&
|
||||||
|
updatedCollectionOrder.splice(source.index, 1);
|
||||||
|
|
||||||
|
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
|
||||||
|
|
||||||
|
await updateAccount({
|
||||||
|
...account,
|
||||||
|
collectionOrder: updatedCollectionOrder,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
destination.index !== undefined &&
|
||||||
|
destination.parentId === "root"
|
||||||
|
) {
|
||||||
|
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
|
||||||
|
|
||||||
|
await updateAccount({
|
||||||
|
...account,
|
||||||
|
collectionOrder: updatedCollectionOrder,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
source.parentId === "root" &&
|
||||||
|
destination.parentId &&
|
||||||
|
destination.parentId !== "root"
|
||||||
|
) {
|
||||||
|
updatedCollectionOrder.splice(source.index, 1);
|
||||||
|
|
||||||
|
await updateAccount({
|
||||||
|
...account,
|
||||||
|
collectionOrder: updatedCollectionOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tree) {
|
||||||
|
return <></>;
|
||||||
|
} else
|
||||||
|
return (
|
||||||
|
<Tree
|
||||||
|
tree={tree}
|
||||||
|
renderItem={(itemProps) => renderItem({ ...itemProps }, currentPath)}
|
||||||
|
onExpand={onExpand}
|
||||||
|
onCollapse={onCollapse}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
isDragEnabled
|
||||||
|
isNestingEnabled
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionListing;
|
||||||
|
|
||||||
|
const renderItem = (
|
||||||
|
{ item, onExpand, onCollapse, provided }: RenderItemParams,
|
||||||
|
currentPath: string
|
||||||
|
) => {
|
||||||
|
const collection = item.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={provided.innerRef} {...provided.draggableProps} className="mb-1">
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
currentPath === `/collections/${collection.id}`
|
||||||
|
? "bg-primary/20 is-active"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
|
||||||
|
>
|
||||||
|
{Icon(item as ExtendedTreeItem, onExpand, onCollapse)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/collections/${collection.id}`}
|
||||||
|
className="w-full"
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="bi-folder-fill text-2xl drop-shadow"
|
||||||
|
style={{ color: collection.color }}
|
||||||
|
></i>
|
||||||
|
<p className="truncate w-full">{collection.name}</p>
|
||||||
|
|
||||||
|
{collection.isPublic ? (
|
||||||
|
<i
|
||||||
|
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
||||||
|
title="This collection is being shared publicly."
|
||||||
|
></i>
|
||||||
|
) : undefined}
|
||||||
|
<div className="drop-shadow text-neutral text-xs">
|
||||||
|
{collection._count?.links}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = (
|
||||||
|
item: ExtendedTreeItem,
|
||||||
|
onExpand: (id: ItemId) => void,
|
||||||
|
onCollapse: (id: ItemId) => void
|
||||||
|
) => {
|
||||||
|
if (item.children && item.children.length > 0) {
|
||||||
|
return item.isExpanded ? (
|
||||||
|
<button onClick={() => onCollapse(item.id)}>
|
||||||
|
<div className="bi-caret-down-fill opacity-50 hover:opacity-100 duration-200"></div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => onExpand(item.id)}>
|
||||||
|
<div className="bi-caret-right-fill opacity-40 hover:opacity-100 duration-200"></div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// return <span>•</span>;
|
||||||
|
return <div></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTreeFromCollections = (
|
||||||
|
collections: CollectionIncludingMembersAndLinkCount[],
|
||||||
|
router: ReturnType<typeof useRouter>,
|
||||||
|
order?: number[]
|
||||||
|
): TreeData => {
|
||||||
|
if (order) {
|
||||||
|
collections.sort((a: any, b: any) => {
|
||||||
|
return order.indexOf(a.id) - order.indexOf(b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: { [key: string]: ExtendedTreeItem } = collections.reduce(
|
||||||
|
(acc: any, collection) => {
|
||||||
|
acc[collection.id as number] = {
|
||||||
|
id: collection.id,
|
||||||
|
children: [],
|
||||||
|
hasChildren: false,
|
||||||
|
isExpanded: false,
|
||||||
|
data: {
|
||||||
|
id: collection.id,
|
||||||
|
parentId: collection.parentId,
|
||||||
|
name: collection.name,
|
||||||
|
description: collection.description,
|
||||||
|
color: collection.color,
|
||||||
|
isPublic: collection.isPublic,
|
||||||
|
ownerId: collection.ownerId,
|
||||||
|
createdAt: collection.createdAt,
|
||||||
|
updatedAt: collection.updatedAt,
|
||||||
|
_count: {
|
||||||
|
links: collection._count?.links,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCollectionId = Number(router.asPath.split("/collections/")[1]);
|
||||||
|
|
||||||
|
if (activeCollectionId) {
|
||||||
|
for (const item in items) {
|
||||||
|
const collection = items[item];
|
||||||
|
if (Number(item) === activeCollectionId && collection.data.parentId) {
|
||||||
|
// get all the parents of the active collection recursively until root and set isExpanded to true
|
||||||
|
let parentId = collection.data.parentId || null;
|
||||||
|
while (parentId && items[parentId]) {
|
||||||
|
items[parentId].isExpanded = true;
|
||||||
|
parentId = items[parentId].data.parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
collections.forEach((collection) => {
|
||||||
|
const parentId = collection.parentId;
|
||||||
|
if (parentId && items[parentId] && collection.id) {
|
||||||
|
items[parentId].children.push(collection.id);
|
||||||
|
items[parentId].hasChildren = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootId = "root";
|
||||||
|
items[rootId] = {
|
||||||
|
id: rootId,
|
||||||
|
children: (collections
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
c.parentId === null || !collections.find((i) => i.id === c.parentId)
|
||||||
|
)
|
||||||
|
.map((c) => c.id) || "") as unknown as string[],
|
||||||
|
hasChildren: true,
|
||||||
|
isExpanded: true,
|
||||||
|
data: { name: "Root" } as Collection,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { rootId, items };
|
||||||
|
};
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import useCollectionStore from "@/store/collections";
|
|
||||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
links: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CollectionSelection = ({ links }: Props) => {
|
|
||||||
const { collections } = useCollectionStore();
|
|
||||||
const [active, setActive] = useState("");
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActive(router.asPath);
|
|
||||||
}, [router, collections]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{collections[0] ? (
|
|
||||||
collections
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.filter((e) => e.parentId === null)
|
|
||||||
.map((e, i) => (
|
|
||||||
<CollectionItem
|
|
||||||
key={i}
|
|
||||||
collection={e}
|
|
||||||
active={active}
|
|
||||||
collections={collections}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
|
||||||
>
|
|
||||||
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
|
|
||||||
You Have No Collections...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CollectionSelection;
|
|
||||||
|
|
||||||
const CollectionItem = ({
|
|
||||||
collection,
|
|
||||||
active,
|
|
||||||
collections,
|
|
||||||
}: {
|
|
||||||
collection: CollectionIncludingMembersAndLinkCount;
|
|
||||||
active: string;
|
|
||||||
collections: CollectionIncludingMembersAndLinkCount[];
|
|
||||||
}) => {
|
|
||||||
const hasChildren = collections.some((e) => e.parentId === collection.id);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Check if the current collection or any of its subcollections is active
|
|
||||||
const isActiveOrParentOfActive = React.useMemo(() => {
|
|
||||||
const isActive = active === `/collections/${collection.id}`;
|
|
||||||
if (isActive) return true;
|
|
||||||
|
|
||||||
const checkIfParentOfActive = (parentId: number): boolean => {
|
|
||||||
return collections.some((e) => {
|
|
||||||
if (e.id === parseInt(active.split("/collections/")[1])) {
|
|
||||||
if (e.parentId === parentId) return true;
|
|
||||||
if (e.parentId) return checkIfParentOfActive(e.parentId);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return checkIfParentOfActive(collection.id as number);
|
|
||||||
}, [active, collection.id, collections]);
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(isActiveOrParentOfActive);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsOpen(isActiveOrParentOfActive);
|
|
||||||
}, [isActiveOrParentOfActive]);
|
|
||||||
|
|
||||||
return hasChildren ? (
|
|
||||||
<details open={isOpen}>
|
|
||||||
<summary
|
|
||||||
className={`${
|
|
||||||
active === `/collections/${collection.id}`
|
|
||||||
? "bg-primary/20"
|
|
||||||
: "hover:bg-neutral/20"
|
|
||||||
} duration-100 rounded-md flex w-full items-center cursor-pointer mb-1 px-2`}
|
|
||||||
>
|
|
||||||
<Link href={`/collections/${collection.id}`} className="w-full">
|
|
||||||
<div
|
|
||||||
className={`py-1 cursor-pointer flex items-center gap-2 w-full h-8 capitalize`}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="bi-folder-fill text-2xl drop-shadow"
|
|
||||||
style={{ color: collection.color }}
|
|
||||||
></i>
|
|
||||||
<p className="truncate w-full">{collection.name}</p>
|
|
||||||
|
|
||||||
{collection.isPublic ? (
|
|
||||||
<i
|
|
||||||
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
|
||||||
title="This collection is being shared publicly."
|
|
||||||
></i>
|
|
||||||
) : undefined}
|
|
||||||
<div className="drop-shadow text-neutral text-xs">
|
|
||||||
{collection._count?.links}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
{hasChildren && (
|
|
||||||
<div className="ml-3 pl-1 border-l border-neutral-content">
|
|
||||||
{collections
|
|
||||||
.filter((e) => e.parentId === collection.id)
|
|
||||||
.map((subCollection) => (
|
|
||||||
<CollectionItem
|
|
||||||
key={subCollection.id}
|
|
||||||
collection={subCollection}
|
|
||||||
active={active}
|
|
||||||
collections={collections}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</details>
|
|
||||||
) : (
|
|
||||||
<Link href={`/collections/${collection.id}`} className="w-full">
|
|
||||||
<div
|
|
||||||
className={`${
|
|
||||||
active === `/collections/${collection.id}`
|
|
||||||
? "bg-primary/20"
|
|
||||||
: "hover:bg-neutral/20"
|
|
||||||
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize mb-1`}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="bi-folder-fill text-2xl drop-shadow"
|
|
||||||
style={{ color: collection.color }}
|
|
||||||
></i>
|
|
||||||
<p className="truncate w-full">{collection.name}</p>
|
|
||||||
|
|
||||||
{collection.isPublic ? (
|
|
||||||
<i
|
|
||||||
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
|
||||||
title="This collection is being shared publicly."
|
|
||||||
></i>
|
|
||||||
) : undefined}
|
|
||||||
<div className="drop-shadow text-neutral text-xs">
|
|
||||||
{collection._count?.links}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -26,7 +26,7 @@ export default function FilterSearchDropdown({
|
|||||||
>
|
>
|
||||||
<i className="bi-funnel text-neutral text-2xl"></i>
|
<i className="bi-funnel text-neutral text-2xl"></i>
|
||||||
</div>
|
</div>
|
||||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1">
|
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-56 mt-1">
|
||||||
<li>
|
<li>
|
||||||
<label
|
<label
|
||||||
className="label cursor-pointer flex justify-start"
|
className="label cursor-pointer flex justify-start"
|
||||||
@@ -84,27 +84,6 @@ export default function FilterSearchDropdown({
|
|||||||
<span className="label-text">Description</span>
|
<span className="label-text">Description</span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<label
|
|
||||||
className="label cursor-pointer flex justify-start"
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="search-filter-checkbox"
|
|
||||||
className="checkbox checkbox-primary"
|
|
||||||
checked={searchFilter.textContent}
|
|
||||||
onChange={() => {
|
|
||||||
setSearchFilter({
|
|
||||||
...searchFilter,
|
|
||||||
textContent: !searchFilter.textContent,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="label-text">Full Content</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<label
|
<label
|
||||||
className="label cursor-pointer flex justify-start"
|
className="label cursor-pointer flex justify-start"
|
||||||
@@ -126,6 +105,29 @@ export default function FilterSearchDropdown({
|
|||||||
<span className="label-text">Tags</span>
|
<span className="label-text">Tags</span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-between"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="search-filter-checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
checked={searchFilter.textContent}
|
||||||
|
onChange={() => {
|
||||||
|
setSearchFilter({
|
||||||
|
...searchFilter,
|
||||||
|
textContent: !searchFilter.textContent,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Full Content</span>
|
||||||
|
|
||||||
|
<div className="ml-auto badge badge-sm badge-neutral">Slower</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,18 +4,26 @@ import { useEffect, useState } from "react";
|
|||||||
import { styles } from "./styles";
|
import { styles } from "./styles";
|
||||||
import { Options } from "./types";
|
import { Options } from "./types";
|
||||||
import CreatableSelect from "react-select/creatable";
|
import CreatableSelect from "react-select/creatable";
|
||||||
|
import Select from "react-select";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChange: any;
|
onChange: any;
|
||||||
defaultValue:
|
showDefaultValue?: boolean;
|
||||||
|
defaultValue?:
|
||||||
| {
|
| {
|
||||||
label: string;
|
label: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
creatable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
export default function CollectionSelection({
|
||||||
|
onChange,
|
||||||
|
defaultValue,
|
||||||
|
showDefaultValue = true,
|
||||||
|
creatable = true,
|
||||||
|
}: Props) {
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -36,22 +44,87 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const formatedCollections = collections.map((e) => {
|
const formatedCollections = collections.map((e) => {
|
||||||
return { value: e.id, label: e.name, ownerId: e.ownerId };
|
return {
|
||||||
|
value: e.id,
|
||||||
|
label: e.name,
|
||||||
|
ownerId: e.ownerId,
|
||||||
|
count: e._count,
|
||||||
|
parentId: e.parentId,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
setOptions(formatedCollections);
|
setOptions(formatedCollections);
|
||||||
}, [collections]);
|
}, [collections]);
|
||||||
|
|
||||||
return (
|
const getParentNames = (parentId: number): string[] => {
|
||||||
<CreatableSelect
|
const parentNames = [];
|
||||||
isClearable={false}
|
const parent = collections.find((e) => e.id === parentId);
|
||||||
className="react-select-container"
|
|
||||||
classNamePrefix="react-select"
|
if (parent) {
|
||||||
onChange={onChange}
|
parentNames.push(parent.name);
|
||||||
options={options}
|
if (parent.parentId) {
|
||||||
styles={styles}
|
parentNames.push(...getParentNames(parent.parentId));
|
||||||
defaultValue={defaultValue}
|
}
|
||||||
// menuPosition="fixed"
|
}
|
||||||
/>
|
|
||||||
);
|
// Have the top level parent at beginning
|
||||||
|
return parentNames.reverse();
|
||||||
|
};
|
||||||
|
|
||||||
|
const customOption = ({ data, innerProps }: any) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...innerProps}
|
||||||
|
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex w-full justify-between items-center">
|
||||||
|
<span>{data.label}</span>
|
||||||
|
<span className="text-sm text-neutral">{data.count?.links}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
{getParentNames(data?.parentId).length > 0 ? (
|
||||||
|
<>
|
||||||
|
{getParentNames(data.parentId).join(" > ")} {">"} {data.label}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
data.label
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (creatable) {
|
||||||
|
return (
|
||||||
|
<CreatableSelect
|
||||||
|
isClearable={false}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
styles={styles}
|
||||||
|
defaultValue={showDefaultValue ? defaultValue : null}
|
||||||
|
components={{
|
||||||
|
Option: customOption,
|
||||||
|
}}
|
||||||
|
// menuPosition="fixed"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
isClearable={false}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
styles={styles}
|
||||||
|
defaultValue={showDefaultValue ? defaultValue : null}
|
||||||
|
components={{
|
||||||
|
Option: customOption,
|
||||||
|
}}
|
||||||
|
// menuPosition="fixed"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import LinkCard from "@/components/LinkViews/LinkCard";
|
import LinkCard from "@/components/LinkViews/LinkCard";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import { link } from "fs";
|
||||||
|
import { GridLoader } from "react-spinners";
|
||||||
|
|
||||||
export default function CardView({
|
export default function CardView({
|
||||||
links,
|
links,
|
||||||
|
editMode,
|
||||||
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
links: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
editMode?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||||
@@ -15,9 +21,19 @@ export default function CardView({
|
|||||||
link={e}
|
link={e}
|
||||||
count={i}
|
count={i}
|
||||||
flipDropdown={i === links.length - 1}
|
flipDropdown={i === links.length - 1}
|
||||||
|
editMode={editMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{isLoading && links.length > 0 && (
|
||||||
|
<GridLoader
|
||||||
|
color="oklch(var(--p))"
|
||||||
|
loading={true}
|
||||||
|
size={20}
|
||||||
|
className="fixed top-5 right-5 opacity-50 z-30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import LinkList from "@/components/LinkViews/LinkList";
|
import LinkList from "@/components/LinkViews/LinkList";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import { GridLoader } from "react-spinners";
|
||||||
|
|
||||||
export default function ListView({
|
export default function ListView({
|
||||||
links,
|
links,
|
||||||
|
editMode,
|
||||||
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
links: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
editMode?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex gap-1 flex-col">
|
||||||
{links.map((e, i) => {
|
{links.map((e, i) => {
|
||||||
return (
|
return (
|
||||||
<LinkList
|
<LinkList
|
||||||
@@ -15,9 +20,19 @@ export default function ListView({
|
|||||||
link={e}
|
link={e}
|
||||||
count={i}
|
count={i}
|
||||||
flipDropdown={i === links.length - 1}
|
flipDropdown={i === links.length - 1}
|
||||||
|
editMode={editMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{isLoading && links.length > 0 && (
|
||||||
|
<GridLoader
|
||||||
|
color="oklch(var(--p))"
|
||||||
|
loading={true}
|
||||||
|
size={20}
|
||||||
|
className="fixed top-5 right-5 opacity-50 z-30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,25 +14,41 @@ import Image from "next/image";
|
|||||||
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
|
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import LinkIcon from "./LinkComponents/LinkIcon";
|
import LinkIcon from "./LinkComponents/LinkIcon";
|
||||||
import LinkGroupedIconURL from "./LinkComponents/LinkGroupedIconURL";
|
|
||||||
import useOnScreen from "@/hooks/useOnScreen";
|
import useOnScreen from "@/hooks/useOnScreen";
|
||||||
|
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
count: number;
|
count: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
flipDropdown?: boolean;
|
flipDropdown?: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkGrid({
|
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
||||||
link,
|
|
||||||
count,
|
|
||||||
className,
|
|
||||||
flipDropdown,
|
|
||||||
}: Props) {
|
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
const { account } = useAccountStore();
|
||||||
|
|
||||||
const { links, getLink } = useLinkStore();
|
const { links, getLink, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editMode) {
|
||||||
|
setSelectedLinks([]);
|
||||||
|
}
|
||||||
|
}, [editMode]);
|
||||||
|
|
||||||
|
const handleCheckboxClick = (
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
|
) => {
|
||||||
|
if (selectedLinks.includes(link)) {
|
||||||
|
setSelectedLinks(selectedLinks.filter((e) => e !== link));
|
||||||
|
} else {
|
||||||
|
setSelectedLinks([...selectedLinks, link]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let shortendURL;
|
let shortendURL;
|
||||||
|
|
||||||
@@ -59,6 +75,7 @@ export default function LinkGrid({
|
|||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const isVisible = useOnScreen(ref);
|
const isVisible = useOnScreen(ref);
|
||||||
|
const permissions = usePermissions(collection?.id as number);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: any;
|
let interval: any;
|
||||||
@@ -82,15 +99,36 @@ export default function LinkGrid({
|
|||||||
|
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
|
const selectedStyle = selectedLinks.some(
|
||||||
|
(selectedLink) => selectedLink.id === link.id
|
||||||
|
)
|
||||||
|
? "border-primary bg-base-300"
|
||||||
|
: "border-neutral-content";
|
||||||
|
|
||||||
|
const selectable =
|
||||||
|
editMode &&
|
||||||
|
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||||
|
|
||||||
|
// window.open ('www.yourdomain.com', '_ blank');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative"
|
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`}
|
||||||
|
onClick={() =>
|
||||||
|
selectable
|
||||||
|
? handleCheckboxClick(link)
|
||||||
|
: editMode
|
||||||
|
? toast.error(
|
||||||
|
"You don't have permission to edit or delete this item."
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Link
|
<div
|
||||||
href={link.url || ""}
|
|
||||||
target="_blank"
|
|
||||||
className="rounded-2xl cursor-pointer"
|
className="rounded-2xl cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
!editMode && window.open(generateLinkHref(link, account), "_blank")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
||||||
{previewAvailable(link) ? (
|
{previewAvailable(link) ? (
|
||||||
@@ -112,15 +150,7 @@ export default function LinkGrid({
|
|||||||
) : (
|
) : (
|
||||||
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
|
||||||
style={
|
|
||||||
{
|
|
||||||
// background:
|
|
||||||
// "radial-gradient(circle, rgba(255, 255, 255, 0.5), transparent)",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"
|
|
||||||
>
|
|
||||||
<LinkIcon link={link} />
|
<LinkIcon link={link} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,27 +162,33 @@ export default function LinkGrid({
|
|||||||
{unescapeString(link.name || link.description) || link.url}
|
{unescapeString(link.name || link.description) || link.url}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div title={link.url || ""} className="w-fit">
|
<Link
|
||||||
<div className="flex gap-1 item-center select-none text-neutral mt-1">
|
href={link.url || ""}
|
||||||
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
|
target="_blank"
|
||||||
<p className="text-sm truncate">{shortendURL}</p>
|
title={link.url || ""}
|
||||||
</div>
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
|
||||||
|
>
|
||||||
|
<i className="bi-link-45deg text-lg mt-[0.10rem] leading-none"></i>
|
||||||
|
<p className="text-sm truncate">{shortendURL}</p>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
|
||||||
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||||
<div className="cursor-pointer w-fit">
|
<div className="cursor-pointer w-fit">
|
||||||
{collection ? (
|
{collection && (
|
||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection link={link} collection={collection} />
|
||||||
) : undefined}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<LinkDate link={link} />
|
<LinkDate link={link} />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
|
|
||||||
{showInfo ? (
|
{showInfo && (
|
||||||
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
onClick={() => setShowInfo(!showInfo)}
|
onClick={() => setShowInfo(!showInfo)}
|
||||||
@@ -172,7 +208,7 @@ export default function LinkGrid({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{link.tags[0] ? (
|
{link.tags[0] && (
|
||||||
<>
|
<>
|
||||||
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
|
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
|
||||||
|
|
||||||
@@ -195,9 +231,9 @@ export default function LinkGrid({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
)}
|
||||||
|
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
|
|||||||
@@ -80,22 +80,20 @@ export default function LinkActions({
|
|||||||
<i title="More" className="bi-three-dots text-xl" />
|
<i title="More" className="bi-three-dots text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 translate-y-10">
|
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 translate-y-10">
|
||||||
{permissions === true ? (
|
<li>
|
||||||
<li>
|
<div
|
||||||
<div
|
role="button"
|
||||||
role="button"
|
tabIndex={0}
|
||||||
tabIndex={0}
|
onClick={() => {
|
||||||
onClick={() => {
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
(document?.activeElement as HTMLElement)?.blur();
|
pinLink();
|
||||||
pinLink();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{link?.pinnedBy && link.pinnedBy[0]
|
||||||
{link?.pinnedBy && link.pinnedBy[0]
|
? "Unpin"
|
||||||
? "Unpin"
|
: "Pin to Dashboard"}
|
||||||
: "Pin to Dashboard"}
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
|
||||||
) : undefined}
|
|
||||||
{linkInfo !== undefined && toggleShowInfo ? (
|
{linkInfo !== undefined && toggleShowInfo ? (
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
CollectionIncludingMembersAndLinkCount,
|
CollectionIncludingMembersAndLinkCount,
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
} from "@/types/global";
|
} from "@/types/global";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
@@ -15,12 +16,12 @@ export default function LinkCollection({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
|
href={`/collections/${link.collection.id}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
router.push(`/collections/${link.collection.id}`);
|
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
|
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
|
||||||
title={collection?.name}
|
title={collection?.name}
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
@@ -28,6 +29,6 @@ export default function LinkCollection({
|
|||||||
style={{ color: collection?.color }}
|
style={{ color: collection?.color }}
|
||||||
></i>
|
></i>
|
||||||
<p className="truncate capitalize">{collection?.name}</p>
|
<p className="truncate capitalize">{collection?.name}</p>
|
||||||
</div>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ export default function LinkDate({
|
|||||||
}: {
|
}: {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
}) {
|
}) {
|
||||||
const formattedDate = new Date(link.createdAt as string).toLocaleString(
|
const formattedDate = new Date(
|
||||||
"en-US",
|
(link.importDate || link.createdAt) as string
|
||||||
{
|
).toLocaleString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 text-neutral">
|
<div className="flex items-center gap-1 text-neutral">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkGrid({ link, count, className }: Props) {
|
export default function LinkGrid({ link }: Props) {
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
const { links } = useLinkStore();
|
const { links } = useLinkStore();
|
||||||
|
|||||||
@@ -12,23 +12,49 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
|
|||||||
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { isPWA } from "@/lib/client/utils";
|
import { isPWA } from "@/lib/client/utils";
|
||||||
|
import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
count: number;
|
count: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
flipDropdown?: boolean;
|
flipDropdown?: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LinkCardCompact({
|
export default function LinkCardCompact({
|
||||||
link,
|
link,
|
||||||
count,
|
|
||||||
className,
|
|
||||||
flipDropdown,
|
flipDropdown,
|
||||||
|
editMode,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
const { account } = useAccountStore();
|
||||||
|
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
|
||||||
|
|
||||||
const { links } = useLinkStore();
|
useEffect(() => {
|
||||||
|
if (!editMode) {
|
||||||
|
setSelectedLinks([]);
|
||||||
|
}
|
||||||
|
}, [editMode]);
|
||||||
|
|
||||||
|
const handleCheckboxClick = (
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
|
) => {
|
||||||
|
const linkIndex = selectedLinks.findIndex(
|
||||||
|
(selectedLink) => selectedLink.id === link.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (linkIndex !== -1) {
|
||||||
|
const updatedLinks = [...selectedLinks];
|
||||||
|
updatedLinks.splice(linkIndex, 1);
|
||||||
|
setSelectedLinks(updatedLinks);
|
||||||
|
} else {
|
||||||
|
setSelectedLinks([...selectedLinks, link]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let shortendURL;
|
let shortendURL;
|
||||||
|
|
||||||
@@ -53,26 +79,62 @@ export default function LinkCardCompact({
|
|||||||
);
|
);
|
||||||
}, [collections, links]);
|
}, [collections, links]);
|
||||||
|
|
||||||
|
const permissions = usePermissions(collection?.id as number);
|
||||||
|
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
|
const selectedStyle = selectedLinks.some(
|
||||||
|
(selectedLink) => selectedLink.id === link.id
|
||||||
|
)
|
||||||
|
? "border border-primary bg-base-300"
|
||||||
|
: "border-transparent";
|
||||||
|
|
||||||
|
const selectable =
|
||||||
|
editMode &&
|
||||||
|
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`border-neutral-content relative ${
|
className={`${selectedStyle} border relative items-center flex ${
|
||||||
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
|
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
|
||||||
} duration-200 rounded-lg`}
|
} duration-200 rounded-lg`}
|
||||||
|
onClick={() =>
|
||||||
|
selectable
|
||||||
|
? handleCheckboxClick(link)
|
||||||
|
: editMode
|
||||||
|
? toast.error(
|
||||||
|
"You don't have permission to edit or delete this item."
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Link
|
{/* {showCheckbox &&
|
||||||
href={link.url || ""}
|
editMode &&
|
||||||
target="_blank"
|
(permissions === true ||
|
||||||
className="flex items-start cursor-pointer"
|
permissions?.canCreate ||
|
||||||
|
permissions?.canDelete) && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary my-auto mr-2"
|
||||||
|
checked={selectedLinks.some(
|
||||||
|
(selectedLink) => selectedLink.id === link.id
|
||||||
|
)}
|
||||||
|
onChange={() => handleCheckboxClick(link)}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
<div
|
||||||
|
className="flex items-center cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
!editMode && window.open(generateLinkHref(link, account), "_blank")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[calc(100%-56px)] ml-2">
|
<div className="w-[calc(100%-56px)] ml-2">
|
||||||
<p className="line-clamp-1 mr-8 text-primary">
|
<p className="line-clamp-1 mr-8 text-primary select-none">
|
||||||
{unescapeString(link.name || link.description) || link.url}
|
{unescapeString(link.name || link.description) || link.url}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -82,12 +144,20 @@ export default function LinkCardCompact({
|
|||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection link={link} collection={collection} />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{link.url ? (
|
{link.url ? (
|
||||||
<div className="flex items-center gap-1 w-fit text-neutral truncate">
|
<Link
|
||||||
<i className="bi-link-45deg text-lg" />
|
href={link.url || ""}
|
||||||
<p className="truncate w-full">{shortendURL}</p>
|
target="_blank"
|
||||||
</div>
|
title={link.url || ""}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
|
||||||
|
>
|
||||||
|
<i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i>
|
||||||
|
<p className="text-sm truncate">{shortendURL}</p>
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="badge badge-primary badge-sm my-1">
|
<div className="badge badge-primary badge-sm my-1 select-none">
|
||||||
{link.type}
|
{link.type}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -95,8 +165,7 @@ export default function LinkCardCompact({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
|
|
||||||
<LinkActions
|
<LinkActions
|
||||||
link={link}
|
link={link}
|
||||||
collection={collection}
|
collection={collection}
|
||||||
@@ -105,52 +174,7 @@ export default function LinkCardCompact({
|
|||||||
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||||
// linkInfo={showInfo}
|
// linkInfo={showInfo}
|
||||||
/>
|
/>
|
||||||
{showInfo ? (
|
|
||||||
<div>
|
|
||||||
<div className="pb-3 mt-1 px-3">
|
|
||||||
<p className="text-neutral text-lg font-semibold">Description</p>
|
|
||||||
|
|
||||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
|
||||||
<p>
|
|
||||||
{link.description ? (
|
|
||||||
unescapeString(link.description)
|
|
||||||
) : (
|
|
||||||
<span className="text-neutral text-sm">
|
|
||||||
No description provided.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{link.tags[0] ? (
|
|
||||||
<>
|
|
||||||
<p className="text-neutral text-lg mt-3 font-semibold">
|
|
||||||
Tags
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
|
||||||
|
|
||||||
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
|
||||||
<div className="flex gap-1 items-center flex-wrap">
|
|
||||||
{link.tags.map((e, i) => (
|
|
||||||
<Link
|
|
||||||
href={"/tags/" + e.id}
|
|
||||||
key={i}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
|
||||||
>
|
|
||||||
#{e.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divider my-0 last:hidden h-[1px]"></div>
|
<div className="divider my-0 last:hidden h-[1px]"></div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from "react";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BulkDeleteLinksModal({ onClose }: Props) {
|
||||||
|
const { selectedLinks, setSelectedLinks, deleteLinksById } = useLinkStore();
|
||||||
|
|
||||||
|
const deleteLink = async () => {
|
||||||
|
const load = toast.loading(
|
||||||
|
`Deleting ${selectedLinks.length} Link${
|
||||||
|
selectedLinks.length > 1 ? "s" : ""
|
||||||
|
}...`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await deleteLinksById(
|
||||||
|
selectedLinks.map((link) => link.id as number)
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(
|
||||||
|
`Deleted ${selectedLinks.length} Link${
|
||||||
|
selectedLinks.length > 1 ? "s" : ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedLinks([]);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin text-red-500">
|
||||||
|
Delete {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{selectedLinks.length > 1 ? (
|
||||||
|
<p>Are you sure you want to delete {selectedLinks.length} links?</p>
|
||||||
|
) : (
|
||||||
|
<p>Are you sure you want to delete this link?</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div role="alert" className="alert alert-warning">
|
||||||
|
<i className="bi-exclamation-triangle text-xl" />
|
||||||
|
<span>
|
||||||
|
<b>Warning:</b> This action is irreversible!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
|
||||||
|
'Delete' to bypass this confirmation in the future.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
|
||||||
|
onClick={deleteLink}
|
||||||
|
>
|
||||||
|
<i className="bi-trash text-xl" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
||||||
|
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BulkEditLinksModal({ onClose }: Props) {
|
||||||
|
const { updateLinks, selectedLinks, setSelectedLinks } = useLinkStore();
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
const [removePreviousTags, setRemovePreviousTags] = useState(false);
|
||||||
|
const [updatedValues, setUpdatedValues] = useState<
|
||||||
|
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
|
||||||
|
>({ tags: [] });
|
||||||
|
|
||||||
|
const setCollection = (e: any) => {
|
||||||
|
const collectionId = e?.value || null;
|
||||||
|
console.log(updatedValues);
|
||||||
|
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTags = (e: any) => {
|
||||||
|
const tags = e.map((tag: any) => ({ name: tag.label }));
|
||||||
|
setUpdatedValues((prevValues) => ({ ...prevValues, tags }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading("Updating...");
|
||||||
|
|
||||||
|
const response = await updateLinks(
|
||||||
|
selectedLinks,
|
||||||
|
removePreviousTags,
|
||||||
|
updatedValues
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(`Updated!`);
|
||||||
|
setSelectedLinks([]);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">
|
||||||
|
Edit {selectedLinks.length} Link{selectedLinks.length > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Move to Collection</p>
|
||||||
|
<CollectionSelection
|
||||||
|
showDefaultValue={false}
|
||||||
|
onChange={setCollection}
|
||||||
|
creatable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Add Tags</p>
|
||||||
|
<TagSelection onChange={setTags} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sm:ml-auto w-1/2 p-3">
|
||||||
|
<label className="flex items-center gap-2 ">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
checked={removePreviousTags}
|
||||||
|
onChange={(e) => setRemovePreviousTags(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Remove previous tags
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end items-center mt-5">
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
|||||||
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||||
|
|
||||||
const { removeLink } = useLinkStore();
|
const { removeLink } = useLinkStore();
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -234,11 +234,8 @@ export default function EditCollectionSharingModal({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<React.Fragment key={i}>
|
||||||
<div
|
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none">
|
||||||
key={i}
|
|
||||||
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={"flex items-center justify-between w-full"}
|
className={"flex items-center justify-between w-full"}
|
||||||
>
|
>
|
||||||
@@ -433,7 +430,7 @@ export default function EditCollectionSharingModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divider my-0 last:hidden h-[3px]"></div>
|
<div className="divider my-0 last:hidden h-[3px]"></div>
|
||||||
</>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
|
|||||||
label: "Unorganized",
|
label: "Unorganized",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
creatable={false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { HexColorPicker } from "react-colorful";
|
|||||||
import { Collection } from "@prisma/client";
|
import { Collection } from "@prisma/client";
|
||||||
import Modal from "../Modal";
|
import Modal from "../Modal";
|
||||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose: Function;
|
onClose: Function;
|
||||||
@@ -21,6 +23,8 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
|||||||
} as Partial<Collection>;
|
} as Partial<Collection>;
|
||||||
|
|
||||||
const [collection, setCollection] = useState<Partial<Collection>>(initial);
|
const [collection, setCollection] = useState<Partial<Collection>>(initial);
|
||||||
|
const { setAccount } = useAccountStore();
|
||||||
|
const { data } = useSession();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollection(initial);
|
setCollection(initial);
|
||||||
@@ -42,7 +46,11 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success("Created!");
|
toast.success("Created!");
|
||||||
onClose();
|
if (response.data) {
|
||||||
|
// If the collection was created successfully, we need to get the new collection order
|
||||||
|
setAccount(data?.user.id as number);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
} else toast.error(response.data as string);
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
setSubmitLoader(false);
|
setSubmitLoader(false);
|
||||||
|
|||||||
@@ -109,7 +109,6 @@ export default function NewLinkModal({ onClose }: Props) {
|
|||||||
toast.success(`Created!`);
|
toast.success(`Created!`);
|
||||||
onClose();
|
onClose();
|
||||||
} else toast.error(response.data as string);
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
setSubmitLoader(false);
|
setSubmitLoader(false);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function Navbar() {
|
|||||||
setSidebar(true);
|
setSidebar(true);
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
}}
|
}}
|
||||||
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden hidden sm:inline-flex"
|
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden sm:inline-flex"
|
||||||
>
|
>
|
||||||
<i className="bi-list text-2xl leading-none"></i>
|
<i className="bi-list text-2xl leading-none"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
const [imageError, setImageError] = useState<boolean>(false);
|
const [imageError, setImageError] = useState<boolean>(false);
|
||||||
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
||||||
|
|
||||||
|
const [date, setDate] = useState<Date | string>();
|
||||||
|
|
||||||
const colorThief = new ColorThief();
|
const colorThief = new ColorThief();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -54,6 +56,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchLinkContent();
|
fetchLinkContent();
|
||||||
|
|
||||||
|
setDate(link.importDate || link.createdAt);
|
||||||
}, [link]);
|
}, [link]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -211,8 +215,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="min-w-fit text-sm text-neutral">
|
<p className="min-w-fit text-sm text-neutral">
|
||||||
{link?.createdAt
|
{date
|
||||||
? new Date(link?.createdAt).toLocaleString("en-US", {
|
? new Date(date).toLocaleString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
const LINKWARDEN_VERSION = "v2.4.8";
|
const LINKWARDEN_VERSION = "v2.5.2";
|
||||||
|
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
@@ -37,30 +37,17 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/appearance">
|
<Link href="/settings/preference">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/settings/appearance`
|
active === `/settings/preference`
|
||||||
? "bg-primary/20"
|
? "bg-primary/20"
|
||||||
: "hover:bg-neutral/20"
|
: "hover:bg-neutral/20"
|
||||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<i className="bi-palette text-primary text-2xl"></i>
|
<i className="bi-sliders text-primary text-2xl"></i>
|
||||||
|
|
||||||
<p className="truncate w-full pr-7">Appearance</p>
|
<p className="truncate w-full pr-7">Preference</p>
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/settings/archive">
|
|
||||||
<div
|
|
||||||
className={`${
|
|
||||||
active === `/settings/archive`
|
|
||||||
? "bg-primary/20"
|
|
||||||
: "hover:bg-neutral/20"
|
|
||||||
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
|
||||||
>
|
|
||||||
<i className="bi-archive text-primary text-2xl"></i>
|
|
||||||
<p className="truncate w-full pr-7">Archive</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/router";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
|
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
|
||||||
import CollectionSelection from "@/components/CollectionSelection";
|
import CollectionListing from "@/components/CollectionListing";
|
||||||
|
|
||||||
export default function Sidebar({ className }: { className?: string }) {
|
export default function Sidebar({ className }: { className?: string }) {
|
||||||
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
||||||
@@ -22,11 +22,10 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||||||
|
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
const { tags } = useTagStore();
|
const { tags } = useTagStore();
|
||||||
|
const [active, setActive] = useState("");
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [active, setActive] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
|
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
|
||||||
}, [tagDisclosure]);
|
}, [tagDisclosure]);
|
||||||
@@ -99,7 +98,7 @@ export default function Sidebar({ className }: { className?: string }) {
|
|||||||
leaveTo="transform opacity-0 -translate-y-3"
|
leaveTo="transform opacity-0 -translate-y-3"
|
||||||
>
|
>
|
||||||
<Disclosure.Panel>
|
<Disclosure.Panel>
|
||||||
<CollectionSelection links={true} />
|
<CollectionListing />
|
||||||
</Disclosure.Panel>
|
</Disclosure.Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
onMouseDown={dropdownTriggerer}
|
onMouseDown={dropdownTriggerer}
|
||||||
className="btn btn-sm btn-square btn-ghost"
|
className="btn btn-sm btn-square btn-ghost border-none"
|
||||||
>
|
>
|
||||||
<i className="bi-chevron-expand text-neutral text-2xl"></i>
|
<i className="bi-chevron-expand text-neutral text-2xl"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
import React, { Dispatch, SetStateAction, useEffect } from "react";
|
||||||
import useLocalSettingsStore from "@/store/localSettings";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
|
||||||
import { ViewMode } from "@/types/global";
|
import { ViewMode } from "@/types/global";
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import { Member } from "@/types/global";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function useCollectivePermissions(collectionIds: number[]) {
|
||||||
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
const { account } = useAccountStore();
|
||||||
|
|
||||||
|
const [permissions, setPermissions] = useState<Member | true>();
|
||||||
|
useEffect(() => {
|
||||||
|
for (const collectionId of collectionIds) {
|
||||||
|
const collection = collections.find((e) => e.id === collectionId);
|
||||||
|
|
||||||
|
if (collection) {
|
||||||
|
let getPermission: Member | undefined = collection.members.find(
|
||||||
|
(e) => e.userId === account.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
getPermission?.canCreate === false &&
|
||||||
|
getPermission?.canUpdate === false &&
|
||||||
|
getPermission?.canDelete === false
|
||||||
|
)
|
||||||
|
getPermission = undefined;
|
||||||
|
|
||||||
|
setPermissions(account.id === collection.ownerId || getPermission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [account, collections, collectionIds]);
|
||||||
|
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
+15
-2
@@ -1,5 +1,5 @@
|
|||||||
import { LinkRequestQuery } from "@/types/global";
|
import { LinkRequestQuery } from "@/types/global";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import useDetectPageBottom from "./useDetectPageBottom";
|
import useDetectPageBottom from "./useDetectPageBottom";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
@@ -18,9 +18,12 @@ export default function useLinks(
|
|||||||
searchByTextContent,
|
searchByTextContent,
|
||||||
}: LinkRequestQuery = { sort: 0 }
|
}: LinkRequestQuery = { sort: 0 }
|
||||||
) {
|
) {
|
||||||
const { links, setLinks, resetLinks } = useLinkStore();
|
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } =
|
||||||
|
useLinkStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
|
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
|
||||||
|
|
||||||
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
|
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
|
||||||
@@ -60,16 +63,24 @@ export default function useLinks(
|
|||||||
basePath = "/api/v1/public/collections/links";
|
basePath = "/api/v1/public/collections/links";
|
||||||
} else basePath = "/api/v1/links";
|
} else basePath = "/api/v1/links";
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
const response = await fetch(`${basePath}?${queryString}`);
|
const response = await fetch(`${basePath}?${queryString}`);
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
if (response.ok) setLinks(data.response, isInitialCall);
|
if (response.ok) setLinks(data.response, isInitialCall);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Save the selected links before resetting the links
|
||||||
|
// and then restore the selected links after resetting the links
|
||||||
|
const previouslySelected = selectedLinks;
|
||||||
resetLinks();
|
resetLinks();
|
||||||
|
|
||||||
|
setSelectedLinks(previouslySelected);
|
||||||
getLinks(true);
|
getLinks(true);
|
||||||
}, [
|
}, [
|
||||||
router,
|
router,
|
||||||
@@ -87,4 +98,6 @@ export default function useLinks(
|
|||||||
|
|
||||||
setReachedBottom(false);
|
setReachedBottom(false);
|
||||||
}, [reachedBottom]);
|
}, [reachedBottom]);
|
||||||
|
|
||||||
|
return { isLoading };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { chromium, devices } from "playwright";
|
import { LaunchOptions, chromium, devices } from "playwright";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import createFile from "./storage/createFile";
|
import createFile from "./storage/createFile";
|
||||||
import sendToWayback from "./sendToWayback";
|
import sendToWayback from "./sendToWayback";
|
||||||
@@ -20,8 +20,23 @@ type LinksAndCollectionAndOwner = Link & {
|
|||||||
const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5;
|
const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5;
|
||||||
|
|
||||||
export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||||
const browser = await chromium.launch();
|
// allow user to configure a proxy
|
||||||
const context = await browser.newContext(devices["Desktop Chrome"]);
|
let browserOptions: LaunchOptions = {};
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
browserOptions.proxy = {
|
||||||
|
server: process.env.PROXY,
|
||||||
|
bypass: process.env.PROXY_BYPASS,
|
||||||
|
username: process.env.PROXY_USERNAME,
|
||||||
|
password: process.env.PROXY_PASSWORD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch(browserOptions);
|
||||||
|
const context = await browser.newContext({
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true",
|
||||||
|
});
|
||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
@@ -70,11 +85,11 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
image:
|
image:
|
||||||
user.archiveAsScreenshot && !link.image?.startsWith("archive")
|
user.archiveAsScreenshot && !link.image?.startsWith("archive")
|
||||||
? "pending"
|
? "pending"
|
||||||
: undefined,
|
: "unavailable",
|
||||||
pdf:
|
pdf:
|
||||||
user.archiveAsPDF && !link.pdf?.startsWith("archive")
|
user.archiveAsPDF && !link.pdf?.startsWith("archive")
|
||||||
? "pending"
|
? "pending"
|
||||||
: undefined,
|
: "unavailable",
|
||||||
readable: !link.readable?.startsWith("archive")
|
readable: !link.readable?.startsWith("archive")
|
||||||
? "pending"
|
? "pending"
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -238,6 +253,13 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apply administrator's defined pdf margins or default to 15px
|
||||||
|
const margins = {
|
||||||
|
top: process.env.PDF_MARGIN_TOP || "15px",
|
||||||
|
bottom: process.env.PDF_MARGIN_BOTTOM || "15px",
|
||||||
|
};
|
||||||
|
|
||||||
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
|
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
|
||||||
processingPromises.push(
|
processingPromises.push(
|
||||||
page
|
page
|
||||||
@@ -245,7 +267,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
width: "1366px",
|
width: "1366px",
|
||||||
height: "1931px",
|
height: "1931px",
|
||||||
printBackground: true,
|
printBackground: true,
|
||||||
margin: { top: "15px", bottom: "15px" },
|
margin: margins,
|
||||||
})
|
})
|
||||||
.then((pdf) => {
|
.then((pdf) => {
|
||||||
return createFile({
|
return createFile({
|
||||||
|
|||||||
@@ -32,11 +32,12 @@ export default async function checkSubscriptionByEmail(email: string) {
|
|||||||
customer.subscriptions?.data.some((subscription) => {
|
customer.subscriptions?.data.some((subscription) => {
|
||||||
subscription.current_period_end;
|
subscription.current_period_end;
|
||||||
|
|
||||||
active = subscription.items.data.some(
|
active =
|
||||||
(e) =>
|
subscription.items.data.some(
|
||||||
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
|
(e) =>
|
||||||
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
|
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
|
||||||
);
|
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
|
||||||
|
) || false;
|
||||||
stripeSubscriptionId = subscription.id;
|
stripeSubscriptionId = subscription.id;
|
||||||
currentPeriodStart = subscription.current_period_start * 1000;
|
currentPeriodStart = subscription.current_period_start * 1000;
|
||||||
currentPeriodEnd = subscription.current_period_end * 1000;
|
currentPeriodEnd = subscription.current_period_end * 1000;
|
||||||
@@ -44,7 +45,7 @@ export default async function checkSubscriptionByEmail(email: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
active,
|
active: active || false,
|
||||||
stripeSubscriptionId,
|
stripeSubscriptionId,
|
||||||
currentPeriodStart,
|
currentPeriodStart,
|
||||||
currentPeriodEnd,
|
currentPeriodEnd,
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export default async function deleteCollection(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await removeFromOrders(userId, collectionId);
|
||||||
|
|
||||||
return { response: deletedUsersAndCollectionsRelation, status: 200 };
|
return { response: deletedUsersAndCollectionsRelation, status: 200 };
|
||||||
} else if (collectionIsAccessible?.ownerId !== userId) {
|
} else if (collectionIsAccessible?.ownerId !== userId) {
|
||||||
return { response: "Collection is not accessible.", status: 401 };
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
@@ -57,6 +59,8 @@ export default async function deleteCollection(
|
|||||||
|
|
||||||
await removeFolder({ filePath: `archives/${collectionId}` });
|
await removeFolder({ filePath: `archives/${collectionId}` });
|
||||||
|
|
||||||
|
await removeFromOrders(userId, collectionId);
|
||||||
|
|
||||||
return await prisma.collection.delete({
|
return await prisma.collection.delete({
|
||||||
where: {
|
where: {
|
||||||
id: collectionId,
|
id: collectionId,
|
||||||
@@ -98,3 +102,28 @@ async function deleteSubCollections(collectionId: number) {
|
|||||||
await removeFolder({ filePath: `archives/${subCollection.id}` });
|
await removeFolder({ filePath: `archives/${subCollection.id}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeFromOrders(userId: number, collectionId: number) {
|
||||||
|
const userCollectionOrder = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
collectionOrder: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userCollectionOrder)
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
collectionOrder: {
|
||||||
|
set: userCollectionOrder.collectionOrder.filter(
|
||||||
|
(e: number) => e !== collectionId
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,24 +18,30 @@ export default async function updateCollection(
|
|||||||
if (!(collectionIsAccessible?.ownerId === userId))
|
if (!(collectionIsAccessible?.ownerId === userId))
|
||||||
return { response: "Collection is not accessible.", status: 401 };
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
if (data.parentId) {
|
console.log(data);
|
||||||
const findParentCollection = await prisma.collection.findUnique({
|
|
||||||
where: {
|
|
||||||
id: data.parentId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
ownerId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
if (data.parentId) {
|
||||||
findParentCollection?.ownerId !== userId ||
|
if (data.parentId !== ("root" as any)) {
|
||||||
typeof data.parentId !== "number"
|
const findParentCollection = await prisma.collection.findUnique({
|
||||||
)
|
where: {
|
||||||
return {
|
id: data.parentId,
|
||||||
response: "You are not authorized to create a sub-collection here.",
|
},
|
||||||
status: 403,
|
select: {
|
||||||
};
|
ownerId: true,
|
||||||
|
parentId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
findParentCollection?.ownerId !== userId ||
|
||||||
|
typeof data.parentId !== "number" ||
|
||||||
|
findParentCollection?.parentId === data.parentId
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
response: "You are not authorized to create a sub-collection here.",
|
||||||
|
status: 403,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedCollection = await prisma.$transaction(async () => {
|
const updatedCollection = await prisma.$transaction(async () => {
|
||||||
@@ -51,17 +57,23 @@ export default async function updateCollection(
|
|||||||
where: {
|
where: {
|
||||||
id: collectionId,
|
id: collectionId,
|
||||||
},
|
},
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
name: data.name.trim(),
|
name: data.name.trim(),
|
||||||
description: data.description,
|
description: data.description,
|
||||||
color: data.color,
|
color: data.color,
|
||||||
isPublic: data.isPublic,
|
isPublic: data.isPublic,
|
||||||
parent: {
|
parent:
|
||||||
connect: {
|
data.parentId && data.parentId !== ("root" as any)
|
||||||
id: data.parentId || undefined,
|
? {
|
||||||
},
|
connect: {
|
||||||
},
|
id: data.parentId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: data.parentId === ("root" as any)
|
||||||
|
? {
|
||||||
|
disconnect: true,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
members: {
|
members: {
|
||||||
create: data.members.map((e) => ({
|
create: data.members.map((e) => ({
|
||||||
user: { connect: { id: e.user.id || e.userId } },
|
user: { connect: { id: e.user.id || e.userId } },
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export default async function getCollection(userId: number) {
|
|||||||
_count: {
|
_count: {
|
||||||
select: { links: true },
|
select: { links: true },
|
||||||
},
|
},
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
members: {
|
members: {
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -32,27 +32,6 @@ export default async function postCollection(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const findCollection = await prisma.user.findUnique({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
collections: {
|
|
||||||
where: {
|
|
||||||
name: collection.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkIfCollectionExists = findCollection?.collections[0];
|
|
||||||
|
|
||||||
if (checkIfCollectionExists)
|
|
||||||
return {
|
|
||||||
response: "Oops! There's already a Collection with that name.",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newCollection = await prisma.collection.create({
|
const newCollection = await prisma.collection.create({
|
||||||
data: {
|
data: {
|
||||||
owner: {
|
owner: {
|
||||||
@@ -88,6 +67,17 @@ export default async function postCollection(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
collectionOrder: {
|
||||||
|
push: newCollection.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
createFolder({ filePath: `archives/${newCollection.id}` });
|
createFolder({ filePath: `archives/${newCollection.id}` });
|
||||||
|
|
||||||
return { response: newCollection, status: 200 };
|
return { response: newCollection, status: 200 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
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 getPermission from "@/lib/api/getPermission";
|
||||||
import removeFile from "@/lib/api/storage/removeFile";
|
import removeFile from "@/lib/api/storage/removeFile";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import { Collection, Link, UsersAndCollections } from "@prisma/client";
|
import { UsersAndCollections } from "@prisma/client";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import moveFile from "@/lib/api/storage/moveFile";
|
import moveFile from "@/lib/api/storage/moveFile";
|
||||||
|
|
||||||
@@ -17,13 +17,70 @@ export default async function updateLinkById(
|
|||||||
|
|
||||||
const collectionIsAccessible = await getPermission({ userId, linkId });
|
const collectionIsAccessible = await getPermission({ userId, linkId });
|
||||||
|
|
||||||
|
const isCollectionOwner =
|
||||||
|
collectionIsAccessible?.ownerId === data.collection.ownerId &&
|
||||||
|
data.collection.ownerId === userId;
|
||||||
|
|
||||||
|
const canPinPermission = collectionIsAccessible?.members.some(
|
||||||
|
(e: UsersAndCollections) => e.userId === userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// If the user is able to create a link, they can pin it to their dashboard only.
|
||||||
|
if (canPinPermission) {
|
||||||
|
const updatedLink = await prisma.link.update({
|
||||||
|
where: {
|
||||||
|
id: linkId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
pinnedBy:
|
||||||
|
data?.pinnedBy && data.pinnedBy[0]
|
||||||
|
? { connect: { id: userId } }
|
||||||
|
: { disconnect: { id: userId } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
collection: true,
|
||||||
|
pinnedBy: isCollectionOwner
|
||||||
|
? {
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { response: updatedLink, status: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCollectionIsAccessible = await getPermission({
|
||||||
|
userId,
|
||||||
|
collectionId: data.collection.id,
|
||||||
|
});
|
||||||
|
|
||||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||||
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
|
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
|
||||||
);
|
);
|
||||||
|
|
||||||
const isCollectionOwner =
|
const targetCollectionsAccessible =
|
||||||
collectionIsAccessible?.ownerId === data.collection.ownerId &&
|
targetCollectionIsAccessible?.ownerId === userId;
|
||||||
data.collection.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 =
|
const unauthorizedSwitchCollection =
|
||||||
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
|
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
|
||||||
|
|||||||
@@ -22,8 +22,115 @@ export default async function postLink(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!link.collection.name) {
|
if (!link.collection.id && link.collection.name) {
|
||||||
|
link.collection.name = link.collection.name.trim();
|
||||||
|
|
||||||
|
// find the collection with the name and the user's id
|
||||||
|
const findCollection = await prisma.collection.findFirst({
|
||||||
|
where: {
|
||||||
|
name: link.collection.name,
|
||||||
|
ownerId: userId,
|
||||||
|
parentId: link.collection.parentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (findCollection) {
|
||||||
|
const collectionIsAccessible = await getPermission({
|
||||||
|
userId,
|
||||||
|
collectionId: findCollection.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||||
|
(e: UsersAndCollections) => e.userId === userId && e.canCreate
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
|
||||||
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
|
link.collection.id = findCollection.id;
|
||||||
|
link.collection.ownerId = findCollection.ownerId;
|
||||||
|
} else {
|
||||||
|
const collection = await prisma.collection.create({
|
||||||
|
data: {
|
||||||
|
name: link.collection.name,
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
link.collection.id = collection.id;
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
collectionOrder: {
|
||||||
|
push: link.collection.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (link.collection.id) {
|
||||||
|
const collectionIsAccessible = await getPermission({
|
||||||
|
userId,
|
||||||
|
collectionId: link.collection.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberHasAccess = collectionIsAccessible?.members.some(
|
||||||
|
(e: UsersAndCollections) => e.userId === userId && e.canCreate
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
|
||||||
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
} else if (!link.collection.id) {
|
||||||
link.collection.name = "Unorganized";
|
link.collection.name = "Unorganized";
|
||||||
|
link.collection.parentId = null;
|
||||||
|
|
||||||
|
// find the collection with the name "Unorganized" and the user's id
|
||||||
|
const unorganizedCollection = await prisma.collection.findFirst({
|
||||||
|
where: {
|
||||||
|
name: "Unorganized",
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
link.collection.id = unorganizedCollection?.id;
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
collectionOrder: {
|
||||||
|
push: link.collection.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return { response: "Uncaught error.", status: 500 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user?.preventDuplicateLinks) {
|
||||||
|
const existingLink = await prisma.link.findFirst({
|
||||||
|
where: {
|
||||||
|
url: link.url?.trim(),
|
||||||
|
collection: {
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingLink)
|
||||||
|
return {
|
||||||
|
response: "Link already exists",
|
||||||
|
status: 409,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberOfLinksTheUserHas = await prisma.link.count({
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
@@ -42,22 +149,6 @@ export default async function postLink(
|
|||||||
|
|
||||||
link.collection.name = link.collection.name.trim();
|
link.collection.name = link.collection.name.trim();
|
||||||
|
|
||||||
if (link.collection.id) {
|
|
||||||
const collectionIsAccessible = await getPermission({
|
|
||||||
userId,
|
|
||||||
collectionId: link.collection.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberHasAccess = collectionIsAccessible?.members.some(
|
|
||||||
(e: UsersAndCollections) => e.userId === userId && e.canCreate
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
|
|
||||||
return { response: "Collection is not accessible.", status: 401 };
|
|
||||||
} else {
|
|
||||||
link.collection.ownerId = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
link.description && link.description !== ""
|
link.description && link.description !== ""
|
||||||
? link.description
|
? link.description
|
||||||
@@ -81,22 +172,13 @@ export default async function postLink(
|
|||||||
|
|
||||||
const newLink = await prisma.link.create({
|
const newLink = await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
url: link.url,
|
url: link.url?.trim(),
|
||||||
name: link.name,
|
name: link.name,
|
||||||
description,
|
description,
|
||||||
type: linkType,
|
type: linkType,
|
||||||
collection: {
|
collection: {
|
||||||
connectOrCreate: {
|
connect: {
|
||||||
where: {
|
id: link.collection.id,
|
||||||
name_ownerId: {
|
|
||||||
ownerId: link.collection.ownerId,
|
|
||||||
name: link.collection.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: link.collection.name.trim(),
|
|
||||||
ownerId: userId,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tags: {
|
tags: {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export default async function exportData(userId: number) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
pinnedLinks: true,
|
||||||
|
whitelistedUsers: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
import { parse, Node, Element, TextNode } from "himalaya";
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
@@ -11,6 +13,11 @@ export default async function importFromHTMLFile(
|
|||||||
const dom = new JSDOM(rawData);
|
const dom = new JSDOM(rawData);
|
||||||
const document = dom.window.document;
|
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 bookmarks = document.querySelectorAll("A");
|
||||||
const totalImports = bookmarks.length;
|
const totalImports = bookmarks.length;
|
||||||
|
|
||||||
@@ -28,94 +35,232 @@ export default async function importFromHTMLFile(
|
|||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
const folders = document.querySelectorAll("H3");
|
const jsonData = parse(document.documentElement.outerHTML);
|
||||||
|
|
||||||
await prisma
|
const processedArray = processNodes(jsonData);
|
||||||
.$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];
|
for (const item of processedArray) {
|
||||||
|
console.log(item);
|
||||||
let collectionId = findCollection?.collections[0]?.id;
|
await processBookmarks(userId, item as Element);
|
||||||
|
}
|
||||||
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));
|
|
||||||
|
|
||||||
return { response: "Success.", status: 200 };
|
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.toLowerCase() === "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(",");
|
||||||
|
|
||||||
|
// set date if available
|
||||||
|
const linkDateValue = item?.attributes.find(
|
||||||
|
(e) => e.key.toLowerCase() === "add_date"
|
||||||
|
)?.value;
|
||||||
|
|
||||||
|
const linkDate = linkDateValue
|
||||||
|
? new Date(Number(linkDateValue) * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let linkDesc =
|
||||||
|
(
|
||||||
|
(
|
||||||
|
item?.children?.find(
|
||||||
|
(e) => e.type === "element" && e.tagName === "dd"
|
||||||
|
) as Element
|
||||||
|
)?.children[0] as TextNode
|
||||||
|
)?.content || "";
|
||||||
|
|
||||||
|
if (linkUrl && parentCollectionId) {
|
||||||
|
await createLink(
|
||||||
|
userId,
|
||||||
|
linkUrl,
|
||||||
|
parentCollectionId,
|
||||||
|
linkName,
|
||||||
|
linkDesc,
|
||||||
|
linkTags,
|
||||||
|
linkDate
|
||||||
|
);
|
||||||
|
} 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,
|
||||||
|
linkDesc,
|
||||||
|
linkTags,
|
||||||
|
linkDate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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[],
|
||||||
|
importDate?: Date
|
||||||
|
) => {
|
||||||
|
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,
|
||||||
|
importDate: importDate || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function processNodes(nodes: Node[]) {
|
||||||
|
const findAndProcessDL = (node: Node) => {
|
||||||
|
if (node.type === "element" && node.tagName === "dl") {
|
||||||
|
processDLChildren(node);
|
||||||
|
} else if (
|
||||||
|
node.type === "element" &&
|
||||||
|
node.children &&
|
||||||
|
node.children.length
|
||||||
|
) {
|
||||||
|
node.children.forEach((child) => findAndProcessDL(child));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processDLChildren = (dlNode: Element) => {
|
||||||
|
dlNode.children.forEach((child, i) => {
|
||||||
|
if (child.type === "element" && child.tagName === "dt") {
|
||||||
|
const nextSibling = dlNode.children[i + 1];
|
||||||
|
if (
|
||||||
|
nextSibling &&
|
||||||
|
nextSibling.type === "element" &&
|
||||||
|
nextSibling.tagName === "dd"
|
||||||
|
) {
|
||||||
|
const aElement = child.children.find(
|
||||||
|
(el) => el.type === "element" && el.tagName === "a"
|
||||||
|
);
|
||||||
|
if (aElement && aElement.type === "element") {
|
||||||
|
// Add the 'dd' element as a child of the 'a' element
|
||||||
|
aElement.children.push(nextSibling);
|
||||||
|
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
|
||||||
|
dlNode.children.splice(i + 1, 1);
|
||||||
|
// Adjust the loop counter due to the removal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
nodes.forEach(findAndProcessDL);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,41 +37,20 @@ export default async function importFromLinkwarden(
|
|||||||
for (const e of data.collections) {
|
for (const e of data.collections) {
|
||||||
e.name = e.name.trim();
|
e.name = e.name.trim();
|
||||||
|
|
||||||
const findCollection = await prisma.user.findUnique({
|
const newCollection = await prisma.collection.create({
|
||||||
where: {
|
data: {
|
||||||
id: userId,
|
owner: {
|
||||||
},
|
connect: {
|
||||||
select: {
|
id: userId,
|
||||||
collections: {
|
|
||||||
where: {
|
|
||||||
name: e.name,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
name: e.name,
|
||||||
|
description: e.description,
|
||||||
|
color: e.color,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkIfCollectionExists = findCollection?.collections[0];
|
createFolder({ filePath: `archives/${newCollection.id}` });
|
||||||
|
|
||||||
let collectionId = findCollection?.collections[0]?.id;
|
|
||||||
|
|
||||||
if (!checkIfCollectionExists) {
|
|
||||||
const newCollection = await prisma.collection.create({
|
|
||||||
data: {
|
|
||||||
owner: {
|
|
||||||
connect: {
|
|
||||||
id: userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
name: e.name,
|
|
||||||
description: e.description,
|
|
||||||
color: e.color,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createFolder({ filePath: `archives/${newCollection.id}` });
|
|
||||||
|
|
||||||
collectionId = newCollection.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import Links
|
// Import Links
|
||||||
for (const link of e.links) {
|
for (const link of e.links) {
|
||||||
@@ -82,7 +61,7 @@ export default async function importFromLinkwarden(
|
|||||||
description: link.description,
|
description: link.description,
|
||||||
collection: {
|
collection: {
|
||||||
connect: {
|
connect: {
|
||||||
id: collectionId,
|
id: newCollection.id,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Import Tags
|
// Import Tags
|
||||||
|
|||||||
@@ -183,9 +183,14 @@ export default async function updateUserById(
|
|||||||
email: data.email?.toLowerCase().trim(),
|
email: data.email?.toLowerCase().trim(),
|
||||||
isPrivate: data.isPrivate,
|
isPrivate: data.isPrivate,
|
||||||
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
|
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
|
||||||
|
collectionOrder: data.collectionOrder.filter(
|
||||||
|
(value, index, self) => self.indexOf(value) === index
|
||||||
|
),
|
||||||
archiveAsScreenshot: data.archiveAsScreenshot,
|
archiveAsScreenshot: data.archiveAsScreenshot,
|
||||||
archiveAsPDF: data.archiveAsPDF,
|
archiveAsPDF: data.archiveAsPDF,
|
||||||
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
|
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
|
||||||
|
linksRouteTo: data.linksRouteTo,
|
||||||
|
preventDuplicateLinks: data.preventDuplicateLinks,
|
||||||
password:
|
password:
|
||||||
data.newPassword && data.newPassword !== ""
|
data.newPassword && data.newPassword !== ""
|
||||||
? newHashedPassword
|
? newHashedPassword
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { prisma } from "@/lib/api/db";
|
|||||||
type Props = {
|
type Props = {
|
||||||
userId: number;
|
userId: number;
|
||||||
collectionId?: number;
|
collectionId?: number;
|
||||||
|
collectionName?: string;
|
||||||
linkId?: number;
|
linkId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function getPermission({
|
export default async function getPermission({
|
||||||
userId,
|
userId,
|
||||||
collectionId,
|
collectionId,
|
||||||
|
collectionName,
|
||||||
linkId,
|
linkId,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (linkId) {
|
if (linkId) {
|
||||||
@@ -24,10 +26,11 @@ export default async function getPermission({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return check;
|
return check;
|
||||||
} else if (collectionId) {
|
} else if (collectionId || collectionName) {
|
||||||
const check = await prisma.collection.findFirst({
|
const check = await prisma.collection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: collectionId,
|
id: collectionId || undefined,
|
||||||
|
name: collectionName || undefined,
|
||||||
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
||||||
},
|
},
|
||||||
include: { members: true },
|
include: { members: true },
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
|
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||||
|
|
||||||
export default async function validateUrlSize(url: string) {
|
export default async function validateUrlSize(url: string) {
|
||||||
|
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const httpsAgent = new https.Agent({
|
const httpsAgent = new https.Agent({
|
||||||
rejectUnauthorized:
|
rejectUnauthorized:
|
||||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(url, {
|
let fetchOpts = {
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
agent: httpsAgent,
|
agent: httpsAgent,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (process.env.PROXY) {
|
||||||
|
let proxy = new URL(process.env.PROXY);
|
||||||
|
if (process.env.PROXY_USERNAME) {
|
||||||
|
proxy.username = process.env.PROXY_USERNAME;
|
||||||
|
proxy.password = process.env.PROXY_PASSWORD || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchOpts = {
|
||||||
|
method: "HEAD",
|
||||||
|
agent: new SocksProxyAgent(proxy.toString()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOpts);
|
||||||
|
|
||||||
const totalSizeMB =
|
const totalSizeMB =
|
||||||
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
||||||
|
|||||||
@@ -17,15 +17,7 @@ export default async function verifySubscription(
|
|||||||
|
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
|
|
||||||
if (
|
if (!subscription?.active || currentDate > subscription.currentPeriodEnd) {
|
||||||
subscription &&
|
|
||||||
currentDate > subscription.currentPeriodEnd &&
|
|
||||||
!subscription.active
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscription || currentDate > subscription.currentPeriodEnd) {
|
|
||||||
const {
|
const {
|
||||||
active,
|
active,
|
||||||
stripeSubscriptionId,
|
stripeSubscriptionId,
|
||||||
@@ -59,15 +51,21 @@ export default async function verifySubscription(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.catch((err) => console.log(err));
|
||||||
}
|
} else if (!active) {
|
||||||
|
const subscription = await prisma.subscription.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!active) {
|
if (subscription)
|
||||||
if (user.username)
|
await prisma.subscription.delete({
|
||||||
// await prisma.user.update({
|
where: {
|
||||||
// where: { id: user.id },
|
userId: user.id,
|
||||||
// data: { username: null },
|
},
|
||||||
// });
|
});
|
||||||
return null;
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default async function verifyUser({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (STRIPE_SECRET_KEY) {
|
if (STRIPE_SECRET_KEY) {
|
||||||
const subscribedUser = verifySubscription(user);
|
const subscribedUser = await verifySubscription(user);
|
||||||
|
|
||||||
if (!subscribedUser) {
|
if (!subscribedUser) {
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
|
|||||||
@@ -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 || "";
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
export function screenshotAvailable(link: any) {
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
|
||||||
|
export function screenshotAvailable(
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
link &&
|
link &&
|
||||||
link.image &&
|
link.image &&
|
||||||
@@ -7,13 +11,15 @@ export function screenshotAvailable(link: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pdfAvailable(link: any) {
|
export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) {
|
||||||
return (
|
return (
|
||||||
link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable"
|
link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readabilityAvailable(link: any) {
|
export function readabilityAvailable(
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
link &&
|
link &&
|
||||||
link.readable &&
|
link.readable &&
|
||||||
|
|||||||
+37
-7
@@ -1,5 +1,7 @@
|
|||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
|
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||||
|
|
||||||
export default async function getTitle(url: string) {
|
export default async function getTitle(url: string) {
|
||||||
try {
|
try {
|
||||||
const httpsAgent = new https.Agent({
|
const httpsAgent = new https.Agent({
|
||||||
@@ -7,15 +9,43 @@ export default async function getTitle(url: string) {
|
|||||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(url, {
|
// fetchOpts allows a proxy to be defined
|
||||||
|
let fetchOpts = {
|
||||||
agent: httpsAgent,
|
agent: httpsAgent,
|
||||||
});
|
};
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
// regular expression to find the <title> tag
|
if (process.env.PROXY) {
|
||||||
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
// parse proxy url
|
||||||
if (match) return match[1];
|
let proxy = new URL(process.env.PROXY);
|
||||||
else return "";
|
// if authentication set, apply to proxy URL
|
||||||
|
if (process.env.PROXY_USERNAME) {
|
||||||
|
proxy.username = process.env.PROXY_USERNAME;
|
||||||
|
proxy.password = process.env.PROXY_PASSWORD || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// add socks5 proxy to fetchOpts
|
||||||
|
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
|
||||||
|
}
|
||||||
|
|
||||||
|
const responsePromise = fetch(url, fetchOpts);
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error("Fetch title timeout"));
|
||||||
|
}, 10 * 1000); // Stop after 10 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await Promise.race([responsePromise, timeoutPromise]);
|
||||||
|
|
||||||
|
if ((response as any)?.status) {
|
||||||
|
const text = await (response as any).text();
|
||||||
|
|
||||||
|
// regular expression to find the <title> tag
|
||||||
|
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
||||||
|
if (match) return match[1];
|
||||||
|
else return "";
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkwarden",
|
"name": "linkwarden",
|
||||||
"version": "2.4.8",
|
"version": "0.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||||
@@ -10,15 +10,16 @@
|
|||||||
"seed": "node ./prisma/seed.js"
|
"seed": "node ./prisma/seed.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -k \"next dev\" \"yarn worker:dev\"",
|
"dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --",
|
||||||
"worker:dev": "nodemon --skip-project scripts/worker.ts",
|
"worker:dev": "nodemon --skip-project scripts/worker.ts",
|
||||||
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
||||||
"start": "concurrently \"next start\" \"yarn worker:prod\"",
|
"start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atlaskit/tree": "^8.8.7",
|
||||||
"@auth/prisma-adapter": "^1.0.1",
|
"@auth/prisma-adapter": "^1.0.1",
|
||||||
"@aws-sdk/client-s3": "^3.379.1",
|
"@aws-sdk/client-s3": "^3.379.1",
|
||||||
"@headlessui/react": "^1.7.15",
|
"@headlessui/react": "^1.7.15",
|
||||||
@@ -44,12 +45,14 @@
|
|||||||
"eslint-config-next": "13.4.9",
|
"eslint-config-next": "13.4.9",
|
||||||
"formidable": "^3.5.1",
|
"formidable": "^3.5.1",
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^10.16.4",
|
||||||
|
"himalaya": "^1.1.0",
|
||||||
"jimp": "^0.22.10",
|
"jimp": "^0.22.10",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
"lottie-web": "^5.12.2",
|
"lottie-web": "^5.12.2",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.1",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.3",
|
||||||
"playwright": "^1.35.1",
|
"playwright": "^1.35.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
@@ -58,6 +61,8 @@
|
|||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-image-file-resizer": "^0.4.8",
|
"react-image-file-resizer": "^0.4.8",
|
||||||
"react-select": "^5.7.4",
|
"react-select": "^5.7.4",
|
||||||
|
"react-spinners": "^0.13.8",
|
||||||
|
"socks-proxy-agent": "^8.0.2",
|
||||||
"stripe": "^12.13.0",
|
"stripe": "^12.13.0",
|
||||||
"vaul": "^0.8.8",
|
"vaul": "^0.8.8",
|
||||||
"zustand": "^4.3.8"
|
"zustand": "^4.3.8"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import getLinks from "@/lib/api/controllers/links/getLinks";
|
|||||||
import postLink from "@/lib/api/controllers/links/postLink";
|
import postLink from "@/lib/api/controllers/links/postLink";
|
||||||
import { LinkRequestQuery } from "@/types/global";
|
import { LinkRequestQuery } from "@/types/global";
|
||||||
import verifyUser from "@/lib/api/verifyUser";
|
import verifyUser from "@/lib/api/verifyUser";
|
||||||
|
import deleteLinksById from "@/lib/api/controllers/links/bulk/deleteLinksById";
|
||||||
|
import updateLinks from "@/lib/api/controllers/links/bulk/updateLinks";
|
||||||
|
|
||||||
export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const user = await verifyUser({ req, res });
|
const user = await verifyUser({ req, res });
|
||||||
@@ -39,5 +41,20 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return res.status(newlink.status).json({
|
return res.status(newlink.status).json({
|
||||||
response: newlink.response,
|
response: newlink.response,
|
||||||
});
|
});
|
||||||
|
} else if (req.method === "PUT") {
|
||||||
|
const updated = await updateLinks(
|
||||||
|
user.id,
|
||||||
|
req.body.links,
|
||||||
|
req.body.removePreviousTags,
|
||||||
|
req.body.newData
|
||||||
|
);
|
||||||
|
return res.status(updated.status).json({
|
||||||
|
response: updated.response,
|
||||||
|
});
|
||||||
|
} else if (req.method === "DELETE") {
|
||||||
|
const deleted = await deleteLinksById(user.id, req.body.linkIds);
|
||||||
|
return res.status(deleted.status).json({
|
||||||
|
response: deleted.response,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-24
@@ -24,15 +24,18 @@ import CardView from "@/components/LinkViews/Layouts/CardView";
|
|||||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||||
import Link from "next/link";
|
|
||||||
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
|
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
|
||||||
|
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { settings } = useLocalSettingsStore();
|
const { settings } = useLocalSettingsStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { links } = useLinkStore();
|
const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
|
||||||
|
useLinkStore();
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||||
@@ -81,6 +84,9 @@ export default function Index() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchOwner();
|
fetchOwner();
|
||||||
|
|
||||||
|
// When the collection changes, reset the selected links
|
||||||
|
setSelectedLinks([]);
|
||||||
}, [activeCollection]);
|
}, [activeCollection]);
|
||||||
|
|
||||||
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
||||||
@@ -88,6 +94,13 @@ export default function Index() {
|
|||||||
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
|
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
|
||||||
|
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
|
||||||
|
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMode) return setEditMode(false);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<string>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
localStorage.getItem("viewMode") || ViewMode.Card
|
localStorage.getItem("viewMode") || ViewMode.Card
|
||||||
@@ -102,6 +115,35 @@ export default function Index() {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const LinkComponent = linkView[viewMode];
|
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 (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div
|
<div
|
||||||
@@ -135,7 +177,7 @@ export default function Index() {
|
|||||||
<i className="bi-three-dots text-xl" title="More"></i>
|
<i className="bi-three-dots text-xl" title="More"></i>
|
||||||
</div>
|
</div>
|
||||||
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
|
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
|
||||||
{permissions === true ? (
|
{permissions === true && (
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -148,7 +190,7 @@ export default function Index() {
|
|||||||
Edit Collection Info
|
Edit Collection Info
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
) : undefined}
|
)}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -163,7 +205,7 @@ export default function Index() {
|
|||||||
: "View Team"}
|
: "View Team"}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{permissions === true ? (
|
{permissions === true && (
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -176,7 +218,7 @@ export default function Index() {
|
|||||||
Create Sub-Collection
|
Create Sub-Collection
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
) : undefined}
|
)}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -196,7 +238,7 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeCollection ? (
|
{activeCollection && (
|
||||||
<div className={`min-w-[15rem]`}>
|
<div className={`min-w-[15rem]`}>
|
||||||
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
|
||||||
<div
|
<div
|
||||||
@@ -232,18 +274,17 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-neutral text-sm font-semibold">
|
<p className="text-neutral text-sm font-semibold">
|
||||||
By {collectionOwner.name}
|
By {collectionOwner.name}
|
||||||
{activeCollection.members.length > 0
|
{activeCollection.members.length > 0 &&
|
||||||
? ` and ${activeCollection.members.length} others`
|
` and ${activeCollection.members.length} others`}
|
||||||
: undefined}
|
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
)}
|
||||||
|
|
||||||
{activeCollection?.description ? (
|
{activeCollection?.description && (
|
||||||
<p>{activeCollection?.description}</p>
|
<p>{activeCollection?.description}</p>
|
||||||
) : undefined}
|
)}
|
||||||
|
|
||||||
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
|
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
|
||||||
<fieldset className="border rounded-md p-2 border-neutral-content">
|
<fieldset className="border rounded-md p-2 border-neutral-content">
|
||||||
@@ -272,16 +313,88 @@ export default function Index() {
|
|||||||
|
|
||||||
<div className="divider my-0"></div>
|
<div className="divider my-0"></div>
|
||||||
|
|
||||||
<div className="flex justify-between items-end gap-5">
|
<div className="flex justify-between items-center gap-5">
|
||||||
<p>Showing {activeCollection?._count?.links} results</p>
|
<p>Showing {activeCollection?._count?.links} results</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{links.length > 0 &&
|
||||||
|
(permissions === true ||
|
||||||
|
permissions?.canUpdate ||
|
||||||
|
permissions?.canDelete) && (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditMode(!editMode);
|
||||||
|
setSelectedLinks([]);
|
||||||
|
}}
|
||||||
|
className={`btn btn-square btn-sm btn-ghost ${
|
||||||
|
editMode
|
||||||
|
? "bg-primary/20 hover:bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editMode && links.length > 0 && (
|
||||||
|
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div className="flex gap-3 ml-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
onChange={() => handleSelectAll()}
|
||||||
|
checked={
|
||||||
|
selectedLinks.length === links.length && links.length > 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectedLinks.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
{selectedLinks.length}{" "}
|
||||||
|
{selectedLinks.length === 1 ? "link" : "links"} selected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Nothing selected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkEditLinksModal(true)}
|
||||||
|
className="btn btn-sm btn-accent text-white w-fit ml-auto"
|
||||||
|
disabled={
|
||||||
|
selectedLinks.length === 0 ||
|
||||||
|
!(permissions === true || permissions?.canUpdate)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
e.shiftKey
|
||||||
|
? bulkDeleteLinks()
|
||||||
|
: setBulkDeleteLinksModal(true);
|
||||||
|
}}
|
||||||
|
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
|
||||||
|
disabled={
|
||||||
|
selectedLinks.length === 0 ||
|
||||||
|
!(permissions === true || permissions?.canDelete)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
|
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
|
||||||
<LinkComponent
|
<LinkComponent
|
||||||
|
editMode={editMode}
|
||||||
links={links.filter(
|
links={links.filter(
|
||||||
(e) => e.collection.id === activeCollection?.id
|
(e) => e.collection.id === activeCollection?.id
|
||||||
)}
|
)}
|
||||||
@@ -290,34 +403,48 @@ export default function Index() {
|
|||||||
<NoLinksFound />
|
<NoLinksFound />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeCollection ? (
|
{activeCollection && (
|
||||||
<>
|
<>
|
||||||
{editCollectionModal ? (
|
{editCollectionModal && (
|
||||||
<EditCollectionModal
|
<EditCollectionModal
|
||||||
onClose={() => setEditCollectionModal(false)}
|
onClose={() => setEditCollectionModal(false)}
|
||||||
activeCollection={activeCollection}
|
activeCollection={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
)}
|
||||||
{editCollectionSharingModal ? (
|
{editCollectionSharingModal && (
|
||||||
<EditCollectionSharingModal
|
<EditCollectionSharingModal
|
||||||
onClose={() => setEditCollectionSharingModal(false)}
|
onClose={() => setEditCollectionSharingModal(false)}
|
||||||
activeCollection={activeCollection}
|
activeCollection={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
)}
|
||||||
{newCollectionModal ? (
|
{newCollectionModal && (
|
||||||
<NewCollectionModal
|
<NewCollectionModal
|
||||||
onClose={() => setNewCollectionModal(false)}
|
onClose={() => setNewCollectionModal(false)}
|
||||||
parent={activeCollection}
|
parent={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
)}
|
||||||
{deleteCollectionModal ? (
|
{deleteCollectionModal && (
|
||||||
<DeleteCollectionModal
|
<DeleteCollectionModal
|
||||||
onClose={() => setDeleteCollectionModal(false)}
|
onClose={() => setDeleteCollectionModal(false)}
|
||||||
activeCollection={activeCollection}
|
activeCollection={activeCollection}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
)}
|
||||||
|
{bulkDeleteLinksModal && (
|
||||||
|
<BulkDeleteLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkDeleteLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkEditLinksModal && (
|
||||||
|
<BulkEditLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkEditLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : undefined}
|
)}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-4
@@ -3,24 +3,73 @@ import SortDropdown from "@/components/SortDropdown";
|
|||||||
import useLinks from "@/hooks/useLinks";
|
import useLinks from "@/hooks/useLinks";
|
||||||
import MainLayout from "@/layouts/MainLayout";
|
import MainLayout from "@/layouts/MainLayout";
|
||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import { Sort, ViewMode } from "@/types/global";
|
import { Member, Sort, ViewMode } from "@/types/global";
|
||||||
import ViewDropdown from "@/components/ViewDropdown";
|
import ViewDropdown from "@/components/ViewDropdown";
|
||||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||||
|
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
|
||||||
|
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
|
||||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
export default function Links() {
|
export default function Links() {
|
||||||
const { links } = useLinkStore();
|
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||||
|
useLinkStore();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<string>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
localStorage.getItem("viewMode") || ViewMode.Card
|
localStorage.getItem("viewMode") || ViewMode.Card
|
||||||
);
|
);
|
||||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
|
||||||
|
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMode) return setEditMode(false);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const collectivePermissions = useCollectivePermissions(
|
||||||
|
selectedLinks.map((link) => link.collectionId as number)
|
||||||
|
);
|
||||||
|
|
||||||
useLinks({ sort: sortBy });
|
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 = {
|
const linkView = {
|
||||||
[ViewMode.Card]: CardView,
|
[ViewMode.Card]: CardView,
|
||||||
// [ViewMode.Grid]: GridView,
|
// [ViewMode.Grid]: GridView,
|
||||||
@@ -41,17 +90,105 @@ export default function Links() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center justify-end gap-2">
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditMode(!editMode);
|
||||||
|
setSelectedLinks([]);
|
||||||
|
}}
|
||||||
|
className={`btn btn-square btn-sm btn-ghost ${
|
||||||
|
editMode
|
||||||
|
? "bg-primary/20 hover:bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editMode && links.length > 0 && (
|
||||||
|
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div className="flex gap-3 ml-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
onChange={() => handleSelectAll()}
|
||||||
|
checked={
|
||||||
|
selectedLinks.length === links.length && links.length > 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectedLinks.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
{selectedLinks.length}{" "}
|
||||||
|
{selectedLinks.length === 1 ? "link" : "links"} selected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Nothing selected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkEditLinksModal(true)}
|
||||||
|
className="btn btn-sm btn-accent text-white w-fit ml-auto"
|
||||||
|
disabled={
|
||||||
|
selectedLinks.length === 0 ||
|
||||||
|
!(
|
||||||
|
collectivePermissions === true ||
|
||||||
|
collectivePermissions?.canUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
e.shiftKey
|
||||||
|
? bulkDeleteLinks()
|
||||||
|
: setBulkDeleteLinksModal(true);
|
||||||
|
}}
|
||||||
|
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
|
||||||
|
disabled={
|
||||||
|
selectedLinks.length === 0 ||
|
||||||
|
!(
|
||||||
|
collectivePermissions === true ||
|
||||||
|
collectivePermissions?.canDelete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{links[0] ? (
|
{links[0] ? (
|
||||||
<LinkComponent links={links} />
|
<LinkComponent editMode={editMode} links={links} />
|
||||||
) : (
|
) : (
|
||||||
<NoLinksFound text="You Haven't Created Any Links Yet" />
|
<NoLinksFound text="You Haven't Created Any Links Yet" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{bulkDeleteLinksModal && (
|
||||||
|
<BulkDeleteLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkDeleteLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkEditLinksModal && (
|
||||||
|
<BulkEditLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkEditLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-3
@@ -2,16 +2,22 @@ import SortDropdown from "@/components/SortDropdown";
|
|||||||
import useLinks from "@/hooks/useLinks";
|
import useLinks from "@/hooks/useLinks";
|
||||||
import MainLayout from "@/layouts/MainLayout";
|
import MainLayout from "@/layouts/MainLayout";
|
||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import { Sort, ViewMode } from "@/types/global";
|
import { Sort, ViewMode } from "@/types/global";
|
||||||
import ViewDropdown from "@/components/ViewDropdown";
|
import ViewDropdown from "@/components/ViewDropdown";
|
||||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
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 GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
export default function PinnedLinks() {
|
export default function PinnedLinks() {
|
||||||
const { links } = useLinkStore();
|
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||||
|
useLinkStore();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<string>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
localStorage.getItem("viewMode") || ViewMode.Card
|
localStorage.getItem("viewMode") || ViewMode.Card
|
||||||
@@ -20,6 +26,48 @@ export default function PinnedLinks() {
|
|||||||
|
|
||||||
useLinks({ sort: sortBy, pinnedOnly: true });
|
useLinks({ sort: sortBy, pinnedOnly: true });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
|
||||||
|
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMode) 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 = {
|
const linkView = {
|
||||||
[ViewMode.Card]: CardView,
|
[ViewMode.Card]: CardView,
|
||||||
// [ViewMode.Grid]: GridView,
|
// [ViewMode.Grid]: GridView,
|
||||||
@@ -39,13 +87,87 @@ export default function PinnedLinks() {
|
|||||||
description={"Pinned Links from your Collections"}
|
description={"Pinned Links from your Collections"}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex items-center justify-end gap-2">
|
<div className="mt-2 flex items-center justify-end gap-2">
|
||||||
|
{!(links.length === 0) && (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditMode(!editMode);
|
||||||
|
setSelectedLinks([]);
|
||||||
|
}}
|
||||||
|
className={`btn btn-square btn-sm btn-ghost ${
|
||||||
|
editMode
|
||||||
|
? "bg-primary/20 hover:bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editMode && links.length > 0 && (
|
||||||
|
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div className="flex gap-3 ml-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
onChange={() => handleSelectAll()}
|
||||||
|
checked={
|
||||||
|
selectedLinks.length === links.length && links.length > 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectedLinks.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
{selectedLinks.length}{" "}
|
||||||
|
{selectedLinks.length === 1 ? "link" : "links"} selected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Nothing selected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkEditLinksModal(true)}
|
||||||
|
className="btn btn-sm btn-accent text-white w-fit ml-auto"
|
||||||
|
disabled={
|
||||||
|
selectedLinks.length === 0 ||
|
||||||
|
!(
|
||||||
|
collectivePermissions === true ||
|
||||||
|
collectivePermissions?.canUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
e.shiftKey
|
||||||
|
? bulkDeleteLinks()
|
||||||
|
: setBulkDeleteLinksModal(true);
|
||||||
|
}}
|
||||||
|
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
|
||||||
|
disabled={
|
||||||
|
selectedLinks.length === 0 ||
|
||||||
|
!(
|
||||||
|
collectivePermissions === true ||
|
||||||
|
collectivePermissions?.canDelete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
|
||||||
<LinkComponent links={links} />
|
<LinkComponent editMode={editMode} links={links} />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{ flex: "1 1 auto" }}
|
style={{ flex: "1 1 auto" }}
|
||||||
@@ -62,6 +184,20 @@ export default function PinnedLinks() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{bulkDeleteLinksModal && (
|
||||||
|
<BulkDeleteLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkDeleteLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkEditLinksModal && (
|
||||||
|
<BulkEditLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkEditLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export default function PublicCollections() {
|
|||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
description: true,
|
description: true,
|
||||||
textContent: true,
|
|
||||||
tags: true,
|
tags: true,
|
||||||
|
textContent: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||||
|
|||||||
+21
-7
@@ -5,12 +5,13 @@ import MainLayout from "@/layouts/MainLayout";
|
|||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
import { Sort, ViewMode } from "@/types/global";
|
import { Sort, ViewMode } from "@/types/global";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import ViewDropdown from "@/components/ViewDropdown";
|
import ViewDropdown from "@/components/ViewDropdown";
|
||||||
import CardView from "@/components/LinkViews/Layouts/CardView";
|
import CardView from "@/components/LinkViews/Layouts/CardView";
|
||||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import { GridLoader, PropagateLoader } from "react-spinners";
|
||||||
|
|
||||||
export default function Search() {
|
export default function Search() {
|
||||||
const { links } = useLinkStore();
|
const { links } = useLinkStore();
|
||||||
@@ -21,8 +22,8 @@ export default function Search() {
|
|||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
description: true,
|
description: true,
|
||||||
textContent: true,
|
|
||||||
tags: true,
|
tags: true,
|
||||||
|
textContent: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<string>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
@@ -30,7 +31,7 @@ export default function Search() {
|
|||||||
);
|
);
|
||||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||||
|
|
||||||
useLinks({
|
const { isLoading } = useLinks({
|
||||||
sort: sortBy,
|
sort: sortBy,
|
||||||
searchQueryString: decodeURIComponent(router.query.q as string),
|
searchQueryString: decodeURIComponent(router.query.q as string),
|
||||||
searchByName: searchFilter.name,
|
searchByName: searchFilter.name,
|
||||||
@@ -40,6 +41,10 @@ export default function Search() {
|
|||||||
searchByTags: searchFilter.tags,
|
searchByTags: searchFilter.tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("isLoading", isLoading);
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
const linkView = {
|
const linkView = {
|
||||||
[ViewMode.Card]: CardView,
|
[ViewMode.Card]: CardView,
|
||||||
// [ViewMode.Grid]: GridView,
|
// [ViewMode.Grid]: GridView,
|
||||||
@@ -51,7 +56,7 @@ export default function Search() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div className="p-5 flex flex-col gap-5 w-full">
|
<div className="p-5 flex flex-col gap-5 w-full h-full">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<PageHeader icon={"bi-search"} title={"Search Results"} />
|
<PageHeader icon={"bi-search"} title={"Search Results"} />
|
||||||
|
|
||||||
@@ -67,15 +72,24 @@ export default function Search() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{links[0] ? (
|
{!isLoading && !links[0] ? (
|
||||||
<LinkComponent links={links} />
|
|
||||||
) : (
|
|
||||||
<p>
|
<p>
|
||||||
Nothing found.{" "}
|
Nothing found.{" "}
|
||||||
<span className="font-bold text-xl" title="Shruggie">
|
<span className="font-bold text-xl" title="Shruggie">
|
||||||
¯\_(ツ)_/¯
|
¯\_(ツ)_/¯
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
) : links[0] ? (
|
||||||
|
<LinkComponent links={links} isLoading={isLoading} />
|
||||||
|
) : (
|
||||||
|
isLoading && (
|
||||||
|
<GridLoader
|
||||||
|
color="oklch(var(--p))"
|
||||||
|
loading={true}
|
||||||
|
size={20}
|
||||||
|
className="m-auto py-10"
|
||||||
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
|
|||||||
@@ -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<AccountSettings>(
|
|
||||||
!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 (
|
|
||||||
<SettingsLayout>
|
|
||||||
<p className="capitalize text-3xl font-thin inline">Appearance</p>
|
|
||||||
|
|
||||||
<div className="divider my-3"></div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div>
|
|
||||||
<p className="mb-3">Select Theme</p>
|
|
||||||
<div className="flex gap-3 w-full">
|
|
||||||
<div
|
|
||||||
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${
|
|
||||||
localStorage.getItem("theme") === "dark"
|
|
||||||
? "dark:outline-primary text-primary"
|
|
||||||
: "text-white"
|
|
||||||
}`}
|
|
||||||
onClick={() => updateSettings({ theme: "dark" })}
|
|
||||||
>
|
|
||||||
<i className="bi-moon-fill text-6xl"></i>
|
|
||||||
<p className="ml-2 text-2xl">Dark</p>
|
|
||||||
|
|
||||||
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-white ${
|
|
||||||
localStorage.getItem("theme") === "light"
|
|
||||||
? "outline-primary text-primary"
|
|
||||||
: "text-black"
|
|
||||||
}`}
|
|
||||||
onClick={() => updateSettings({ theme: "light" })}
|
|
||||||
>
|
|
||||||
<i className="bi-sun-fill text-6xl"></i>
|
|
||||||
<p className="ml-2 text-2xl">Light</p>
|
|
||||||
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* <SubmitButton
|
|
||||||
onClick={submit}
|
|
||||||
loading={submitLoader}
|
|
||||||
label="Save Changes"
|
|
||||||
className="mt-2 mx-auto lg:mx-0"
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<AccountSettings>(account);
|
|
||||||
|
|
||||||
const [archiveAsScreenshot, setArchiveAsScreenshot] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
|
|
||||||
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
|
|
||||||
useState<boolean>(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 (
|
|
||||||
<SettingsLayout>
|
|
||||||
<p className="capitalize text-3xl font-thin inline">Archive Settings</p>
|
|
||||||
|
|
||||||
<div className="divider my-3"></div>
|
|
||||||
|
|
||||||
<p>Formats to Archive/Preserve webpages:</p>
|
|
||||||
<div className="p-3">
|
|
||||||
<Checkbox
|
|
||||||
label="Screenshot"
|
|
||||||
state={archiveAsScreenshot}
|
|
||||||
onClick={() => setArchiveAsScreenshot(!archiveAsScreenshot)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
label="PDF"
|
|
||||||
state={archiveAsPDF}
|
|
||||||
onClick={() => setArchiveAsPDF(!archiveAsPDF)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
label="Archive.org Snapshot"
|
|
||||||
state={archiveAsWaybackMachine}
|
|
||||||
onClick={() => setArchiveAsWaybackMachine(!archiveAsWaybackMachine)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SubmitButton
|
|
||||||
onClick={submit}
|
|
||||||
loading={submitLoader}
|
|
||||||
label="Save Changes"
|
|
||||||
className="mt-2 w-full sm:w-fit"
|
|
||||||
/>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import SettingsLayout from "@/layouts/SettingsLayout";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import { AccountSettings } from "@/types/global";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import React from "react";
|
||||||
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
import Checkbox from "@/components/Checkbox";
|
||||||
|
import SubmitButton from "@/components/SubmitButton";
|
||||||
|
import { LinksRouteTo } from "@prisma/client";
|
||||||
|
|
||||||
|
export default function Appearance() {
|
||||||
|
const { updateSettings } = useLocalSettingsStore();
|
||||||
|
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
const { account, updateAccount } = useAccountStore();
|
||||||
|
const [user, setUser] = useState<AccountSettings>(account);
|
||||||
|
|
||||||
|
const [preventDuplicateLinks, setPreventDuplicateLinks] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [archiveAsScreenshot, setArchiveAsScreenshot] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
|
||||||
|
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [linksRouteTo, setLinksRouteTo] = useState<LinksRouteTo>(
|
||||||
|
user.linksRouteTo
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUser({
|
||||||
|
...account,
|
||||||
|
archiveAsScreenshot,
|
||||||
|
archiveAsPDF,
|
||||||
|
archiveAsWaybackMachine,
|
||||||
|
linksRouteTo,
|
||||||
|
preventDuplicateLinks,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
account,
|
||||||
|
archiveAsScreenshot,
|
||||||
|
archiveAsPDF,
|
||||||
|
archiveAsWaybackMachine,
|
||||||
|
linksRouteTo,
|
||||||
|
preventDuplicateLinks,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function objectIsEmpty(obj: object) {
|
||||||
|
return Object.keys(obj).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!objectIsEmpty(account)) {
|
||||||
|
setArchiveAsScreenshot(account.archiveAsScreenshot);
|
||||||
|
setArchiveAsPDF(account.archiveAsPDF);
|
||||||
|
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
|
||||||
|
setLinksRouteTo(account.linksRouteTo);
|
||||||
|
setPreventDuplicateLinks(account.preventDuplicateLinks);
|
||||||
|
}
|
||||||
|
}, [account]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading("Applying...");
|
||||||
|
|
||||||
|
const response = await updateAccount({
|
||||||
|
...user,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success("Settings Applied!");
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
setSubmitLoader(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsLayout>
|
||||||
|
<p className="capitalize text-3xl font-thin inline">Preference</p>
|
||||||
|
|
||||||
|
<div className="divider my-3"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<p className="mb-3">Select Theme</p>
|
||||||
|
<div className="flex gap-3 w-full">
|
||||||
|
<div
|
||||||
|
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${
|
||||||
|
localStorage.getItem("theme") === "dark"
|
||||||
|
? "dark:outline-primary text-primary"
|
||||||
|
: "text-white"
|
||||||
|
}`}
|
||||||
|
onClick={() => updateSettings({ theme: "dark" })}
|
||||||
|
>
|
||||||
|
<i className="bi-moon-fill text-6xl"></i>
|
||||||
|
<p className="ml-2 text-2xl">Dark</p>
|
||||||
|
|
||||||
|
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-white ${
|
||||||
|
localStorage.getItem("theme") === "light"
|
||||||
|
? "outline-primary text-primary"
|
||||||
|
: "text-black"
|
||||||
|
}`}
|
||||||
|
onClick={() => updateSettings({ theme: "light" })}
|
||||||
|
>
|
||||||
|
<i className="bi-sun-fill text-6xl"></i>
|
||||||
|
<p className="ml-2 text-2xl">Light</p>
|
||||||
|
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="capitalize text-3xl font-thin inline">
|
||||||
|
Archive Settings
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="divider my-3"></div>
|
||||||
|
|
||||||
|
<p>Formats to Archive/Preserve webpages:</p>
|
||||||
|
<div className="p-3">
|
||||||
|
<Checkbox
|
||||||
|
label="Screenshot"
|
||||||
|
state={archiveAsScreenshot}
|
||||||
|
onClick={() => setArchiveAsScreenshot(!archiveAsScreenshot)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
label="PDF"
|
||||||
|
state={archiveAsPDF}
|
||||||
|
onClick={() => setArchiveAsPDF(!archiveAsPDF)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
label="Archive.org Snapshot"
|
||||||
|
state={archiveAsWaybackMachine}
|
||||||
|
onClick={() =>
|
||||||
|
setArchiveAsWaybackMachine(!archiveAsWaybackMachine)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="capitalize text-3xl font-thin inline">Link Settings</p>
|
||||||
|
|
||||||
|
<div className="divider my-3"></div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<Checkbox
|
||||||
|
label="Prevent duplicate links"
|
||||||
|
state={preventDuplicateLinks}
|
||||||
|
onClick={() => setPreventDuplicateLinks(!preventDuplicateLinks)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Clicking on Links should:</p>
|
||||||
|
<div className="p-3">
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex gap-2 justify-start w-fit"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="link-preference-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Original"
|
||||||
|
checked={linksRouteTo === LinksRouteTo.ORIGINAL}
|
||||||
|
onChange={() => setLinksRouteTo(LinksRouteTo.ORIGINAL)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Open the original content</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex gap-2 justify-start w-fit"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="link-preference-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="PDF"
|
||||||
|
checked={linksRouteTo === LinksRouteTo.PDF}
|
||||||
|
onChange={() => setLinksRouteTo(LinksRouteTo.PDF)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Open PDF, if available</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex gap-2 justify-start w-fit"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="link-preference-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Readable"
|
||||||
|
checked={linksRouteTo === LinksRouteTo.READABLE}
|
||||||
|
onChange={() => setLinksRouteTo(LinksRouteTo.READABLE)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Open Readable, if available</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex gap-2 justify-start w-fit"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="link-preference-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Screenshot"
|
||||||
|
checked={linksRouteTo === LinksRouteTo.SCREENSHOT}
|
||||||
|
onChange={() => setLinksRouteTo(LinksRouteTo.SCREENSHOT)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Open Screenshot, if available</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SubmitButton
|
||||||
|
onClick={submit}
|
||||||
|
loading={submitLoader}
|
||||||
|
label="Save Changes"
|
||||||
|
className="mt-2 w-full sm:w-fit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+142
-4
@@ -1,6 +1,6 @@
|
|||||||
import useLinkStore from "@/store/links";
|
import useLinkStore from "@/store/links";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, use, useEffect, useState } from "react";
|
||||||
import MainLayout from "@/layouts/MainLayout";
|
import MainLayout from "@/layouts/MainLayout";
|
||||||
import useTagStore from "@/store/tags";
|
import useTagStore from "@/store/tags";
|
||||||
import SortDropdown from "@/components/SortDropdown";
|
import SortDropdown from "@/components/SortDropdown";
|
||||||
@@ -12,11 +12,15 @@ import CardView from "@/components/LinkViews/Layouts/CardView";
|
|||||||
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
// import GridView from "@/components/LinkViews/Layouts/GridView";
|
||||||
import ListView from "@/components/LinkViews/Layouts/ListView";
|
import ListView from "@/components/LinkViews/Layouts/ListView";
|
||||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||||
|
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
|
||||||
|
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
|
||||||
|
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { links } = useLinkStore();
|
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
|
||||||
|
useLinkStore();
|
||||||
const { tags, updateTag, removeTag } = useTagStore();
|
const { tags, updateTag, removeTag } = useTagStore();
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
|
||||||
@@ -26,11 +30,30 @@ export default function Index() {
|
|||||||
|
|
||||||
const [activeTag, setActiveTag] = useState<TagIncludingLinkCount>();
|
const [activeTag, setActiveTag] = useState<TagIncludingLinkCount>();
|
||||||
|
|
||||||
|
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
|
||||||
|
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMode) return setEditMode(false);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const collectivePermissions = useCollectivePermissions(
|
||||||
|
selectedLinks.map((link) => link.collectionId as number)
|
||||||
|
);
|
||||||
|
|
||||||
useLinks({ tagId: Number(router.query.id), sort: sortBy });
|
useLinks({ tagId: Number(router.query.id), sort: sortBy });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTag(tags.find((e) => e.id === Number(router.query.id)));
|
const tag = tags.find((e) => e.id === Number(router.query.id));
|
||||||
}, [router, tags]);
|
|
||||||
|
if (tags.length > 0 && !tag?.id) {
|
||||||
|
router.push("/dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTag(tag);
|
||||||
|
}, [router, tags, Number(router.query.id), setActiveTag]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNewTagName(activeTag?.name);
|
setNewTagName(activeTag?.name);
|
||||||
@@ -91,6 +114,35 @@ export default function Index() {
|
|||||||
setRenameTag(false);
|
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<string>(
|
const [viewMode, setViewMode] = useState<string>(
|
||||||
localStorage.getItem("viewMode") || ViewMode.Card
|
localStorage.getItem("viewMode") || ViewMode.Card
|
||||||
);
|
);
|
||||||
@@ -195,16 +247,102 @@ export default function Index() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 items-center mt-2">
|
<div className="flex gap-2 items-center mt-2">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEditMode(!editMode);
|
||||||
|
setSelectedLinks([]);
|
||||||
|
}}
|
||||||
|
className={`btn btn-square btn-sm btn-ghost ${
|
||||||
|
editMode
|
||||||
|
? "bg-primary/20 hover:bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="bi-pencil-fill text-neutral text-xl"></i>
|
||||||
|
</div>
|
||||||
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
|
||||||
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{editMode && links.length > 0 && (
|
||||||
|
<div className="w-full flex justify-between items-center min-h-[32px]">
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div className="flex gap-3 ml-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
onChange={() => handleSelectAll()}
|
||||||
|
checked={
|
||||||
|
selectedLinks.length === links.length && links.length > 0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectedLinks.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
{selectedLinks.length}{" "}
|
||||||
|
{selectedLinks.length === 1 ? "link" : "links"} selected
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Nothing selected</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setBulkEditLinksModal(true)}
|
||||||
|
className="btn btn-sm btn-accent text-white w-fit ml-auto"
|
||||||
|
disabled={
|
||||||
|
selectedLinks.length === 0 ||
|
||||||
|
!(
|
||||||
|
collectivePermissions === true ||
|
||||||
|
collectivePermissions?.canUpdate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
e.shiftKey
|
||||||
|
? bulkDeleteLinks()
|
||||||
|
: setBulkDeleteLinksModal(true);
|
||||||
|
}}
|
||||||
|
className="btn btn-sm bg-red-400 border-red-400 hover:border-red-500 hover:bg-red-500 text-white w-fit ml-auto"
|
||||||
|
disabled={
|
||||||
|
selectedLinks.length === 0 ||
|
||||||
|
!(
|
||||||
|
collectivePermissions === true ||
|
||||||
|
collectivePermissions?.canDelete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<LinkComponent
|
<LinkComponent
|
||||||
|
editMode={editMode}
|
||||||
links={links.filter((e) =>
|
links={links.filter((e) =>
|
||||||
e.tags.some((e) => e.id === Number(router.query.id))
|
e.tags.some((e) => e.id === Number(router.query.id))
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{bulkDeleteLinksModal && (
|
||||||
|
<BulkDeleteLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkDeleteLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkEditLinksModal && (
|
||||||
|
<BulkEditLinksModal
|
||||||
|
onClose={() => {
|
||||||
|
setBulkEditLinksModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Collection_name_ownerId_key";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "collectionOrder" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "preventDuplicateLinks" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Collection_ownerId_idx" ON "Collection"("ownerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "UsersAndCollections_userId_idx" ON "UsersAndCollections"("userId");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Tag_ownerId_idx" ON "Tag"("ownerId");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Link" ADD COLUMN "importDate" TIMESTAMP(3);
|
||||||
+15
-2
@@ -38,9 +38,12 @@ model User {
|
|||||||
tags Tag[]
|
tags Tag[]
|
||||||
pinnedLinks Link[]
|
pinnedLinks Link[]
|
||||||
collectionsJoined UsersAndCollections[]
|
collectionsJoined UsersAndCollections[]
|
||||||
|
collectionOrder Int[] @default([])
|
||||||
whitelistedUsers WhitelistedUser[]
|
whitelistedUsers WhitelistedUser[]
|
||||||
accessTokens AccessToken[]
|
accessTokens AccessToken[]
|
||||||
subscriptions Subscription?
|
subscriptions Subscription?
|
||||||
|
linksRouteTo LinksRouteTo @default(ORIGINAL)
|
||||||
|
preventDuplicateLinks Boolean @default(false)
|
||||||
archiveAsScreenshot Boolean @default(true)
|
archiveAsScreenshot Boolean @default(true)
|
||||||
archiveAsPDF Boolean @default(true)
|
archiveAsPDF Boolean @default(true)
|
||||||
archiveAsWaybackMachine Boolean @default(false)
|
archiveAsWaybackMachine Boolean @default(false)
|
||||||
@@ -49,6 +52,13 @@ model User {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum LinksRouteTo {
|
||||||
|
ORIGINAL
|
||||||
|
PDF
|
||||||
|
READABLE
|
||||||
|
SCREENSHOT
|
||||||
|
}
|
||||||
|
|
||||||
model WhitelistedUser {
|
model WhitelistedUser {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
username String @default("")
|
username String @default("")
|
||||||
@@ -83,8 +93,8 @@ model Collection {
|
|||||||
links Link[]
|
links Link[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
@@unique([name, ownerId])
|
@@index([ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model UsersAndCollections {
|
model UsersAndCollections {
|
||||||
@@ -99,6 +109,7 @@ model UsersAndCollections {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
@@id([userId, collectionId])
|
@@id([userId, collectionId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Link {
|
model Link {
|
||||||
@@ -117,6 +128,7 @@ model Link {
|
|||||||
pdf String?
|
pdf String?
|
||||||
readable String?
|
readable String?
|
||||||
lastPreserved DateTime?
|
lastPreserved DateTime?
|
||||||
|
importDate DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
@@ -131,6 +143,7 @@ model Tag {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
@@unique([name, ownerId])
|
@@unique([name, ownerId])
|
||||||
|
@@index([ownerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Subscription {
|
model Subscription {
|
||||||
|
|||||||
@@ -19,36 +19,16 @@ async function processBatch() {
|
|||||||
url: { not: null },
|
url: { not: null },
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
collection: {
|
|
||||||
owner: {
|
|
||||||
archiveAsScreenshot: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
image: null,
|
image: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
collection: {
|
|
||||||
owner: {
|
|
||||||
archiveAsScreenshot: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
image: "pending",
|
image: "pending",
|
||||||
},
|
},
|
||||||
///////////////////////
|
///////////////////////
|
||||||
{
|
{
|
||||||
collection: {
|
|
||||||
owner: {
|
|
||||||
archiveAsPDF: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pdf: null,
|
pdf: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
collection: {
|
|
||||||
owner: {
|
|
||||||
archiveAsPDF: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pdf: "pending",
|
pdf: "pending",
|
||||||
},
|
},
|
||||||
///////////////////////
|
///////////////////////
|
||||||
@@ -76,36 +56,16 @@ async function processBatch() {
|
|||||||
url: { not: null },
|
url: { not: null },
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
collection: {
|
|
||||||
owner: {
|
|
||||||
archiveAsScreenshot: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
image: null,
|
image: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
collection: {
|
|
||||||
owner: {
|
|
||||||
archiveAsScreenshot: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
image: "pending",
|
image: "pending",
|
||||||
},
|
},
|
||||||
///////////////////////
|
///////////////////////
|
||||||
{
|
{
|
||||||
collection: {
|
|
||||||
owner: {
|
|
||||||
archiveAsPDF: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pdf: null,
|
pdf: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
collection: {
|
|
||||||
owner: {
|
|
||||||
archiveAsPDF: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pdf: "pending",
|
pdf: "pending",
|
||||||
},
|
},
|
||||||
///////////////////////
|
///////////////////////
|
||||||
@@ -115,13 +75,6 @@ async function processBatch() {
|
|||||||
{
|
{
|
||||||
readable: "pending",
|
readable: "pending",
|
||||||
},
|
},
|
||||||
///////////////////////
|
|
||||||
{
|
|
||||||
preview: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
preview: "pending",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
take: archiveTakeCount,
|
take: archiveTakeCount,
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ type ResponseObject = {
|
|||||||
|
|
||||||
type LinkStore = {
|
type LinkStore = {
|
||||||
links: LinkIncludingShortenedCollectionAndTags[];
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
|
||||||
setLinks: (
|
setLinks: (
|
||||||
data: LinkIncludingShortenedCollectionAndTags[],
|
data: LinkIncludingShortenedCollectionAndTags[],
|
||||||
isInitialCall: boolean
|
isInitialCall: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
|
||||||
addLink: (
|
addLink: (
|
||||||
body: LinkIncludingShortenedCollectionAndTags
|
body: LinkIncludingShortenedCollectionAndTags
|
||||||
) => Promise<ResponseObject>;
|
) => Promise<ResponseObject>;
|
||||||
@@ -21,12 +23,22 @@ type LinkStore = {
|
|||||||
updateLink: (
|
updateLink: (
|
||||||
link: LinkIncludingShortenedCollectionAndTags
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
) => Promise<ResponseObject>;
|
) => Promise<ResponseObject>;
|
||||||
|
updateLinks: (
|
||||||
|
links: LinkIncludingShortenedCollectionAndTags[],
|
||||||
|
removePreviousTags: boolean,
|
||||||
|
newData: Pick<
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
"tags" | "collectionId"
|
||||||
|
>
|
||||||
|
) => Promise<ResponseObject>;
|
||||||
removeLink: (linkId: number) => Promise<ResponseObject>;
|
removeLink: (linkId: number) => Promise<ResponseObject>;
|
||||||
|
deleteLinksById: (linkIds: number[]) => Promise<ResponseObject>;
|
||||||
resetLinks: () => void;
|
resetLinks: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useLinkStore = create<LinkStore>()((set) => ({
|
const useLinkStore = create<LinkStore>()((set) => ({
|
||||||
links: [],
|
links: [],
|
||||||
|
selectedLinks: [],
|
||||||
setLinks: async (data, isInitialCall) => {
|
setLinks: async (data, isInitialCall) => {
|
||||||
isInitialCall &&
|
isInitialCall &&
|
||||||
set(() => ({
|
set(() => ({
|
||||||
@@ -45,6 +57,7 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
setSelectedLinks: (links) => set({ selectedLinks: links }),
|
||||||
addLink: async (body) => {
|
addLink: async (body) => {
|
||||||
const response = await fetch("/api/v1/links", {
|
const response = await fetch("/api/v1/links", {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -122,6 +135,41 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||||||
|
|
||||||
return { ok: response.ok, data: data.response };
|
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) => {
|
removeLink: async (linkId) => {
|
||||||
const response = await fetch(`/api/v1/links/${linkId}`, {
|
const response = await fetch(`/api/v1/links/${linkId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -142,6 +190,27 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||||||
|
|
||||||
return { ok: response.ok, data: data.response };
|
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: [] }),
|
resetLinks: () => set({ links: [] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { ViewMode } from "@/types/global";
|
|
||||||
|
|
||||||
type LocalSettings = {
|
type LocalSettings = {
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
|||||||
Vendored
+11
@@ -13,6 +13,7 @@ declare global {
|
|||||||
MAX_LINKS_PER_USER?: string;
|
MAX_LINKS_PER_USER?: string;
|
||||||
ARCHIVE_TAKE_COUNT?: string;
|
ARCHIVE_TAKE_COUNT?: string;
|
||||||
IGNORE_UNAUTHORIZED_CA?: string;
|
IGNORE_UNAUTHORIZED_CA?: string;
|
||||||
|
IGNORE_URL_SIZE_LIMIT?: string;
|
||||||
|
|
||||||
SPACES_KEY?: string;
|
SPACES_KEY?: string;
|
||||||
SPACES_SECRET?: string;
|
SPACES_SECRET?: string;
|
||||||
@@ -36,6 +37,16 @@ declare global {
|
|||||||
NEXT_PUBLIC_TRIAL_PERIOD_DAYS?: string;
|
NEXT_PUBLIC_TRIAL_PERIOD_DAYS?: string;
|
||||||
BASE_URL?: string;
|
BASE_URL?: string;
|
||||||
|
|
||||||
|
// Proxy settings
|
||||||
|
PROXY?: string;
|
||||||
|
PROXY_USERNAME?: string;
|
||||||
|
PROXY_PASSWORD?: string;
|
||||||
|
PROXY_BYPASS?: string;
|
||||||
|
|
||||||
|
// PDF archive settings
|
||||||
|
PDF_MARGIN_TOP?: string;
|
||||||
|
PDF_MARGIN_BOTTOM?: string;
|
||||||
|
|
||||||
//
|
//
|
||||||
// SSO Providers
|
// SSO Providers
|
||||||
//
|
//
|
||||||
|
|||||||
+8
-1
@@ -7,10 +7,16 @@ type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
|
|||||||
export interface LinkIncludingShortenedCollectionAndTags
|
export interface LinkIncludingShortenedCollectionAndTags
|
||||||
extends Omit<
|
extends Omit<
|
||||||
Link,
|
Link,
|
||||||
"id" | "createdAt" | "collectionId" | "updatedAt" | "lastPreserved"
|
| "id"
|
||||||
|
| "createdAt"
|
||||||
|
| "collectionId"
|
||||||
|
| "updatedAt"
|
||||||
|
| "lastPreserved"
|
||||||
|
| "importDate"
|
||||||
> {
|
> {
|
||||||
id?: number;
|
id?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
importDate?: string;
|
||||||
collectionId?: number;
|
collectionId?: number;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
pinnedBy?: {
|
pinnedBy?: {
|
||||||
@@ -33,6 +39,7 @@ export interface CollectionIncludingMembersAndLinkCount
|
|||||||
id?: number;
|
id?: number;
|
||||||
ownerId?: number;
|
ownerId?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
_count?: { links: number };
|
_count?: { links: number };
|
||||||
members: Member[];
|
members: Member[];
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+22
@@ -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[];
|
||||||
|
}
|
||||||
@@ -12,6 +12,15 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
|
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
|
||||||
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
|
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
|
||||||
|
|
||||||
|
"@atlaskit/tree@^8.8.7":
|
||||||
|
version "8.8.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atlaskit/tree/-/tree-8.8.7.tgz#f895137b063f676a490abb0b5deb939a96f51fd7"
|
||||||
|
integrity sha512-ftbFCzZoa5tZh35EdwMEP9lPuBfw19vtB1CcBmDDMP0AnyEXLjUVfVo8kIls6oI4wivYfIWkZgrUlgN+Jk1b0Q==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.0.0"
|
||||||
|
css-box-model "^1.2.0"
|
||||||
|
react-beautiful-dnd-next "11.0.5"
|
||||||
|
|
||||||
"@auth/core@0.9.0":
|
"@auth/core@0.9.0":
|
||||||
version "0.9.0"
|
version "0.9.0"
|
||||||
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.9.0.tgz#7a5d66eea0bc059cef072734698547ae2a0c86a6"
|
resolved "https://registry.yarnpkg.com/@auth/core/-/core-0.9.0.tgz#7a5d66eea0bc059cef072734698547ae2a0c86a6"
|
||||||
@@ -614,6 +623,21 @@
|
|||||||
chalk "^2.0.0"
|
chalk "^2.0.0"
|
||||||
js-tokens "^4.0.0"
|
js-tokens "^4.0.0"
|
||||||
|
|
||||||
|
"@babel/runtime-corejs2@^7.4.5":
|
||||||
|
version "7.24.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.24.0.tgz#23c12d76ac8a7a0ec223c4b0c3b937f9c203fa33"
|
||||||
|
integrity sha512-RZVGq1it0GA1K8rb+z7v7NzecP6VYCMedN7yHsCCIQUMmRXFCPJD8GISdf6uIGj7NDDihg7ieQEzpdpQbUL75Q==
|
||||||
|
dependencies:
|
||||||
|
core-js "^2.6.12"
|
||||||
|
regenerator-runtime "^0.14.0"
|
||||||
|
|
||||||
|
"@babel/runtime@^7.0.0":
|
||||||
|
version "7.24.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.0.tgz#584c450063ffda59697021430cb47101b085951e"
|
||||||
|
integrity sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==
|
||||||
|
dependencies:
|
||||||
|
regenerator-runtime "^0.14.0"
|
||||||
|
|
||||||
"@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
|
"@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"
|
version "7.21.5"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200"
|
||||||
@@ -628,6 +652,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime "^0.14.0"
|
regenerator-runtime "^0.14.0"
|
||||||
|
|
||||||
|
"@babel/runtime@^7.15.4", "@babel/runtime@^7.9.2":
|
||||||
|
version "7.23.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
|
||||||
|
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
|
||||||
|
dependencies:
|
||||||
|
regenerator-runtime "^0.14.0"
|
||||||
|
|
||||||
"@babel/runtime@^7.21.0":
|
"@babel/runtime@^7.21.0":
|
||||||
version "7.23.6"
|
version "7.23.6"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d"
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d"
|
||||||
@@ -1925,6 +1956,14 @@
|
|||||||
"@types/minimatch" "*"
|
"@types/minimatch" "*"
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/hoist-non-react-statics@^3.3.0":
|
||||||
|
version "3.3.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz#dab7867ef789d87e2b4b0003c9d65c49cc44a494"
|
||||||
|
integrity sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
hoist-non-react-statics "^3.3.0"
|
||||||
|
|
||||||
"@types/jsdom@^21.1.3":
|
"@types/jsdom@^21.1.3":
|
||||||
version "21.1.3"
|
version "21.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.3.tgz#a88c5dc65703e1b10b2a7839c12db49662b43ff0"
|
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.3.tgz#a88c5dc65703e1b10b2a7839c12db49662b43ff0"
|
||||||
@@ -1993,6 +2032,16 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react-redux@^7.1.20":
|
||||||
|
version "7.1.33"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.33.tgz#53c5564f03f1ded90904e3c90f77e4bd4dc20b15"
|
||||||
|
integrity sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==
|
||||||
|
dependencies:
|
||||||
|
"@types/hoist-non-react-statics" "^3.3.0"
|
||||||
|
"@types/react" "*"
|
||||||
|
hoist-non-react-statics "^3.3.0"
|
||||||
|
redux "^4.0.0"
|
||||||
|
|
||||||
"@types/react-transition-group@^4.4.0":
|
"@types/react-transition-group@^4.4.0":
|
||||||
version "4.4.5"
|
version "4.4.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416"
|
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416"
|
||||||
@@ -2113,6 +2162,13 @@ agent-base@6:
|
|||||||
dependencies:
|
dependencies:
|
||||||
debug "4"
|
debug "4"
|
||||||
|
|
||||||
|
agent-base@^7.0.2:
|
||||||
|
version "7.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434"
|
||||||
|
integrity sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==
|
||||||
|
dependencies:
|
||||||
|
debug "^4.3.4"
|
||||||
|
|
||||||
ajv@^6.12.3, ajv@^6.12.4:
|
ajv@^6.12.3, ajv@^6.12.4:
|
||||||
version "6.12.6"
|
version "6.12.6"
|
||||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
||||||
@@ -2601,6 +2657,11 @@ cookie@0.5.0, cookie@^0.5.0:
|
|||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
|
||||||
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
|
||||||
|
|
||||||
|
core-js@^2.6.12:
|
||||||
|
version "2.6.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
|
||||||
|
integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
|
||||||
|
|
||||||
core-util-is@1.0.2:
|
core-util-is@1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
|
||||||
@@ -2636,6 +2697,13 @@ crypto-js@^4.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
|
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
|
||||||
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
|
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
|
||||||
|
|
||||||
|
css-box-model@^1.1.2, css-box-model@^1.2.0:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1"
|
||||||
|
integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==
|
||||||
|
dependencies:
|
||||||
|
tiny-invariant "^1.0.6"
|
||||||
|
|
||||||
css-selector-tokenizer@^0.8:
|
css-selector-tokenizer@^0.8:
|
||||||
version "0.8.0"
|
version "0.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz#88267ef6238e64f2215ea2764b3e2cf498b845dd"
|
resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz#88267ef6238e64f2215ea2764b3e2cf498b845dd"
|
||||||
@@ -3713,7 +3781,12 @@ hexoid@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18"
|
resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18"
|
||||||
integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==
|
integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==
|
||||||
|
|
||||||
hoist-non-react-statics@^3.3.1:
|
himalaya@^1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/himalaya/-/himalaya-1.1.0.tgz#31724ae9d35714cd7c6f4be94888953f3604606a"
|
||||||
|
integrity sha512-LLase1dHCRMel68/HZTFft0N0wti0epHr3nNY7ynpLbyZpmrKMQ8YIpiOV77TM97cNpC8Wb2n6f66IRggwdWPw==
|
||||||
|
|
||||||
|
hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
|
||||||
version "3.3.2"
|
version "3.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||||
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||||
@@ -3847,6 +3920,14 @@ iota-array@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/iota-array/-/iota-array-1.0.0.tgz#81ef57fe5d05814cd58c2483632a99c30a0e8087"
|
resolved "https://registry.yarnpkg.com/iota-array/-/iota-array-1.0.0.tgz#81ef57fe5d05814cd58c2483632a99c30a0e8087"
|
||||||
integrity sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==
|
integrity sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==
|
||||||
|
|
||||||
|
ip-address@^9.0.5:
|
||||||
|
version "9.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a"
|
||||||
|
integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==
|
||||||
|
dependencies:
|
||||||
|
jsbn "1.1.0"
|
||||||
|
sprintf-js "^1.1.3"
|
||||||
|
|
||||||
is-arguments@^1.1.1:
|
is-arguments@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
|
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
|
||||||
@@ -4111,6 +4192,11 @@ js-yaml@^4.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
argparse "^2.0.1"
|
argparse "^2.0.1"
|
||||||
|
|
||||||
|
jsbn@1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040"
|
||||||
|
integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==
|
||||||
|
|
||||||
jsbn@~0.1.0:
|
jsbn@~0.1.0:
|
||||||
version "0.1.1"
|
version "0.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
||||||
@@ -4287,6 +4373,11 @@ make-error@^1.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
|
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
|
||||||
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
|
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
|
||||||
|
|
||||||
|
memoize-one@^5.0.4:
|
||||||
|
version "5.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
|
||||||
|
integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==
|
||||||
|
|
||||||
memoize-one@^6.0.0:
|
memoize-one@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
|
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
|
||||||
@@ -4469,7 +4560,7 @@ node-bitmap@0.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/node-bitmap/-/node-bitmap-0.0.1.tgz#180eac7003e0c707618ef31368f62f84b2a69091"
|
resolved "https://registry.yarnpkg.com/node-bitmap/-/node-bitmap-0.0.1.tgz#180eac7003e0c707618ef31368f62f84b2a69091"
|
||||||
integrity sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==
|
integrity sha512-Jx5lPaaLdIaOsj2mVLWMWulXF6GQVdyLvNSxmiYCvZ8Ma2hfKX0POoR2kgKOqz+oFsRreq0yYZjQ2wjE9VNzCA==
|
||||||
|
|
||||||
node-fetch@^2.6.1:
|
node-fetch@^2.6.1, node-fetch@^2.7.0:
|
||||||
version "2.7.0"
|
version "2.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
|
||||||
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
|
||||||
@@ -4959,7 +5050,7 @@ process@^0.11.10:
|
|||||||
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
|
||||||
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
|
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
|
||||||
|
|
||||||
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.8.1:
|
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||||
version "15.8.1"
|
version "15.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
@@ -5010,6 +5101,11 @@ queue-microtask@^1.2.2:
|
|||||||
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||||
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
||||||
|
|
||||||
|
raf-schd@^4.0.0:
|
||||||
|
version "4.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
|
||||||
|
integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
|
||||||
|
|
||||||
raw-body@2.4.1:
|
raw-body@2.4.1:
|
||||||
version "2.4.1"
|
version "2.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
|
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
|
||||||
@@ -5020,6 +5116,20 @@ raw-body@2.4.1:
|
|||||||
iconv-lite "0.4.24"
|
iconv-lite "0.4.24"
|
||||||
unpipe "1.0.0"
|
unpipe "1.0.0"
|
||||||
|
|
||||||
|
react-beautiful-dnd-next@11.0.5:
|
||||||
|
version "11.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-beautiful-dnd-next/-/react-beautiful-dnd-next-11.0.5.tgz#41e693733bbdeb6269b9e4b923a36de2e99ed761"
|
||||||
|
integrity sha512-kM5Mob41HkA3ShS9uXqeMkW51L5bVsfttxfrwwHucu7I6SdnRKCyN78t6QiLH/UJQQ8T4ukI6NeQAQQpGwolkg==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime-corejs2" "^7.4.5"
|
||||||
|
css-box-model "^1.1.2"
|
||||||
|
memoize-one "^5.0.4"
|
||||||
|
raf-schd "^4.0.0"
|
||||||
|
react-redux "^7.0.3"
|
||||||
|
redux "^4.0.1"
|
||||||
|
tiny-invariant "^1.0.4"
|
||||||
|
use-memo-one "^1.1.0"
|
||||||
|
|
||||||
react-colorful@^5.6.1:
|
react-colorful@^5.6.1:
|
||||||
version "5.6.1"
|
version "5.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b"
|
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b"
|
||||||
@@ -5050,6 +5160,23 @@ react-is@^16.13.1, react-is@^16.7.0:
|
|||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||||
|
|
||||||
|
react-is@^17.0.2:
|
||||||
|
version "17.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||||
|
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
||||||
|
|
||||||
|
react-redux@^7.0.3:
|
||||||
|
version "7.2.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d"
|
||||||
|
integrity sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.15.4"
|
||||||
|
"@types/react-redux" "^7.1.20"
|
||||||
|
hoist-non-react-statics "^3.3.2"
|
||||||
|
loose-envify "^1.4.0"
|
||||||
|
prop-types "^15.7.2"
|
||||||
|
react-is "^17.0.2"
|
||||||
|
|
||||||
react-remove-scroll-bar@^2.3.3:
|
react-remove-scroll-bar@^2.3.3:
|
||||||
version "2.3.4"
|
version "2.3.4"
|
||||||
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9"
|
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9"
|
||||||
@@ -5084,6 +5211,11 @@ react-select@^5.7.4:
|
|||||||
react-transition-group "^4.3.0"
|
react-transition-group "^4.3.0"
|
||||||
use-isomorphic-layout-effect "^1.1.2"
|
use-isomorphic-layout-effect "^1.1.2"
|
||||||
|
|
||||||
|
react-spinners@^0.13.8:
|
||||||
|
version "0.13.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.13.8.tgz#5262571be0f745d86bbd49a1e6b49f9f9cb19acc"
|
||||||
|
integrity sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==
|
||||||
|
|
||||||
react-style-singleton@^2.2.1:
|
react-style-singleton@^2.2.1:
|
||||||
version "2.2.1"
|
version "2.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
|
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
|
||||||
@@ -5140,6 +5272,13 @@ readdirp@~3.6.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
picomatch "^2.2.1"
|
picomatch "^2.2.1"
|
||||||
|
|
||||||
|
redux@^4.0.0, redux@^4.0.1:
|
||||||
|
version "4.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
|
||||||
|
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.9.2"
|
||||||
|
|
||||||
regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.3:
|
regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.3:
|
||||||
version "0.13.11"
|
version "0.13.11"
|
||||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
|
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
|
||||||
@@ -5357,6 +5496,28 @@ slash@^4.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
|
resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7"
|
||||||
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
|
integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
|
||||||
|
|
||||||
|
smart-buffer@^4.2.0:
|
||||||
|
version "4.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
|
||||||
|
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
|
||||||
|
|
||||||
|
socks-proxy-agent@^8.0.2:
|
||||||
|
version "8.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz#5acbd7be7baf18c46a3f293a840109a430a640ad"
|
||||||
|
integrity sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==
|
||||||
|
dependencies:
|
||||||
|
agent-base "^7.0.2"
|
||||||
|
debug "^4.3.4"
|
||||||
|
socks "^2.7.1"
|
||||||
|
|
||||||
|
socks@^2.7.1:
|
||||||
|
version "2.7.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.3.tgz#7d8a75d7ce845c0a96f710917174dba0d543a785"
|
||||||
|
integrity sha512-vfuYK48HXCTFD03G/1/zkIls3Ebr2YNa4qU9gHDZdblHLiqhJrJGkY3+0Nx0JpN9qBhJbVObc1CNciT1bIZJxw==
|
||||||
|
dependencies:
|
||||||
|
ip-address "^9.0.5"
|
||||||
|
smart-buffer "^4.2.0"
|
||||||
|
|
||||||
source-map-js@^1.0.2:
|
source-map-js@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||||
@@ -5372,6 +5533,11 @@ spawn-command@0.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e"
|
resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2.tgz#9544e1a43ca045f8531aac1a48cb29bdae62338e"
|
||||||
integrity sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==
|
integrity sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==
|
||||||
|
|
||||||
|
sprintf-js@^1.1.3:
|
||||||
|
version "1.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a"
|
||||||
|
integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==
|
||||||
|
|
||||||
sshpk@^1.7.0:
|
sshpk@^1.7.0:
|
||||||
version "1.17.0"
|
version "1.17.0"
|
||||||
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5"
|
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.17.0.tgz#578082d92d4fe612b13007496e543fa0fbcbe4c5"
|
||||||
@@ -5636,6 +5802,16 @@ tiny-glob@^0.2.9:
|
|||||||
globalyzer "0.1.0"
|
globalyzer "0.1.0"
|
||||||
globrex "^0.1.2"
|
globrex "^0.1.2"
|
||||||
|
|
||||||
|
tiny-invariant@^1.0.4:
|
||||||
|
version "1.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
||||||
|
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
|
||||||
|
|
||||||
|
tiny-invariant@^1.0.6:
|
||||||
|
version "1.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.1.tgz#8560808c916ef02ecfd55e66090df23a4b7aa642"
|
||||||
|
integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==
|
||||||
|
|
||||||
tinycolor2@^1.6.0:
|
tinycolor2@^1.6.0:
|
||||||
version "1.6.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
|
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
|
||||||
@@ -5872,6 +6048,11 @@ use-isomorphic-layout-effect@^1.1.2:
|
|||||||
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
|
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
|
||||||
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
|
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
|
||||||
|
|
||||||
|
use-memo-one@^1.1.0:
|
||||||
|
version "1.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99"
|
||||||
|
integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==
|
||||||
|
|
||||||
use-sidecar@^1.1.2:
|
use-sidecar@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
|
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
|
||||||
|
|||||||
Reference in New Issue
Block a user