Compare commits

...

165 Commits

Author SHA1 Message Date
daniel31x13 80ad01a2d0 minor fix 2024-05-03 10:51:11 -04:00
daniel31x13 915d08a315 finalized administration panel 2024-05-03 10:22:45 -04:00
daniel31x13 08c2ff278f delete user functionality 2024-05-02 09:17:56 -04:00
daniel31x13 154d0d5fb6 add search to user admin 2024-04-24 09:16:34 -04:00
daniel31x13 7856e76b15 basic user listing 2024-04-22 18:00:59 -04:00
daniel31x13 f37a4b9c9e replace maskable logo 2024-04-21 19:21:30 -04:00
Daniel 389db59b28 Merge pull request #570 from QAComet/qacomet/add-toast-button
Add close button and data-testids to toast messages
2024-04-20 10:49:30 -04:00
daniel31x13 b702aa0401 small improvement 2024-04-20 10:49:06 -04:00
daniel31x13 9a92b4d229 code cleanup 2024-04-19 06:16:11 -04:00
QAComet 8278878673 feat: add close button and data-testids to toast messages 2024-04-18 11:34:29 -06:00
daniel31x13 4640c1c966 hotfix 2024-04-18 06:14:28 -04:00
Daniel 49fbbe966c Merge pull request #568 from linkwarden/hotfix/title-fetching
minor fix
2024-04-17 18:31:40 -04:00
daniel31x13 3610e73d3b minor fix 2024-04-17 18:18:50 -04:00
Daniel 76a5dcb90b Merge pull request #567 from linkwarden/hotfix/title-fetching
Hotfix/title fetching
2024-04-17 18:11:03 -04:00
Daniel e51fba41e7 Merge pull request #563 from linkwarden/hotfix/title-fetching
update version number
2024-04-17 18:07:05 -04:00
daniel31x13 e8edd1c9a0 update version number 2024-04-17 18:06:04 -04:00
Daniel f30c652676 Merge pull request #562 from linkwarden/hotfix/title-fetching
added a new env var + bug fixed
2024-04-17 18:03:36 -04:00
daniel31x13 8cf621bc62 added a new env var + bug fixed 2024-04-17 18:02:54 -04:00
Daniel a89274fc03 Merge pull request #507 from GoodM4ven/missing-duplicate-checks
[Enhancement] Accounting for "www." prefix for duplicates
2024-04-15 08:09:10 +03:30
Daniel baadd6c06b Merge branch 'dev' into missing-duplicate-checks 2024-04-15 08:08:22 +03:30
daniel31x13 4a71af8a67 remove trailing slashes + small improvement 2024-04-15 00:37:18 -04:00
daniel31x13 ece09c6f3b minor change 2024-04-09 04:43:20 -04:00
Daniel 189db27c5b Merge pull request #521 from chrisbsmith/authelia
Adds OIDC support for Authelia
2024-04-09 05:20:45 +03:30
Daniel 68d8d403cf Merge pull request #556 from linkwarden/feat/file-uploads
Feat/file uploads
2024-04-09 03:08:11 +03:30
daniel31x13 07b87be7f1 many bug fixes and improvements 2024-04-08 19:35:06 -04:00
daniel31x13 e67fef1d04 progressed file uploads feature (almost done!) 2024-04-01 02:56:54 -04:00
Daniel 87eb2471ff Merge pull request #543 from linkwarden/dev
make the status of the script independent from the app
2024-03-27 19:39:09 +03:30
daniel31x13 58b6f7339c make the status of the script independent from the app 2024-03-27 12:08:19 -04:00
daniel31x13 c659711181 make the status of the script independent from the app 2024-03-27 12:07:29 -04:00
Daniel 5503483502 Merge pull request #542 from linkwarden/dev
Dev
2024-03-27 10:58:27 +03:30
daniel31x13 a6d018fb53 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-03-27 03:28:02 -04:00
daniel31x13 3929f32e63 minor fix 2024-03-27 03:27:59 -04:00
Daniel c08522386b Merge pull request #541 from linkwarden/dev
Dev
2024-03-27 10:52:31 +03:30
Daniel b51a876904 Merge pull request #537 from paulhovey/import_date
Import pinboard description and date
2024-03-27 10:51:39 +03:30
daniel31x13 2e2d7baee1 fix imports 2024-03-27 03:20:00 -04:00
Paul Hovey 495af0a752 adds description and tags parsing for pinboard html import 2024-03-23 14:57:34 -05:00
Daniel 388b9d9184 Merge pull request #531 from linkwarden/dev
added architecture.md file + renamed license file
2024-03-20 17:27:07 +03:30
daniel31x13 ede3882a94 uncomment code 2024-03-20 09:56:14 -04:00
daniel31x13 e5fcf18fa4 added architecture.md file + renamed license file 2024-03-18 18:36:59 -04:00
Daniel a3d3b353a1 Merge pull request #528 from linkwarden/dev
Dev
2024-03-18 02:41:49 +03:30
daniel31x13 546e216ac9 fix browser extension bug 2024-03-17 19:07:51 -04:00
daniel31x13 ffc037b854 bug fixed 2024-03-16 20:09:58 -04:00
Chris Smith cc2d7c863d Add Authelia as a custom oidc source
set a path to browsers outside of /root

Grant root ownership over /data

set umask + perms after yarn build

revert local testing to upstream
2024-03-14 15:01:19 -04:00
Daniel 53a65774f0 Merge pull request #518 from linkwarden/dev
support for arbitrary values in manual installation
2024-03-13 17:26:53 +03:30
daniel31x13 5990d4ce2d support for arbitrary values in manual installation 2024-03-13 09:56:13 -04:00
Daniel ce2eb8eafb Merge pull request #517 from linkwarden/dev
support for other ports in manual installation
2024-03-13 17:21:07 +03:30
daniel31x13 bae4cf1d4f support for other ports in manual installation 2024-03-13 09:48:16 -04:00
Daniel 4e20d71a41 Merge pull request #509 from linkwarden/dev
improved UX + improved performance
2024-03-10 13:39:04 +03:30
daniel31x13 4a0e75c6e5 improved UX + improved performance 2024-03-10 06:08:28 -04:00
GoodM4ven cac90524ed [Enhancement] Accounting for "www." prefix for duplicates 2024-03-08 14:34:56 +03:00
Daniel 9fce74971f Merge pull request #500 from linkwarden/dev
update announcement version number
2024-03-07 02:31:21 +03:30
daniel31x13 3feeecdc1d update announcement version number 2024-03-06 18:00:54 -05:00
Daniel bde7b9aae0 Merge pull request #497 from linkwarden/dev
improved performance
2024-03-06 17:38:45 +03:30
daniel31x13 bda0dc6c87 improved performance 2024-03-06 09:06:38 -05:00
Daniel 7dd254af48 Merge pull request #495 from linkwarden/dev
more efficient logic for the background script
2024-03-06 02:58:42 +03:30
daniel31x13 a57c3114d8 more efficient logic for the background script 2024-03-05 18:28:11 -05:00
Daniel 3969cc5abd Merge pull request #494 from linkwarden/dev
v2.5.0
2024-03-05 22:05:54 +03:30
daniel31x13 252d41886a updated readme 2024-03-05 13:27:15 -05:00
daniel31x13 d8bab2eb24 update version number 2024-03-05 12:13:50 -05:00
daniel31x13 9bfba6037e minor fix 2024-03-05 12:11:40 -05:00
Daniel e59ab23b3d Merge pull request #484 from IsaacWise06/fix-bulk-delete
fix(links): Bulk deleting all links still showing buttons
2024-03-05 17:59:47 +03:30
daniel31x13 01b3b4485e remove old library 2024-03-05 09:23:55 -05:00
daniel31x13 8c76b0d141 minor fix 2024-03-05 09:11:56 -05:00
Daniel d2b867c438 Merge pull request #492 from IsaacWise06/issue/442
feat(links): Allow the user to enable/disable merging duplicates
2024-03-05 17:33:32 +03:30
daniel31x13 f26cd31694 final touch 2024-03-05 09:03:04 -05:00
daniel31x13 8dcd2c67d2 bug fixes 2024-03-05 08:50:47 -05:00
Isaac Wise 750aa294d0 Allow users to enable merging links 2024-03-04 23:24:30 -06:00
Daniel 281b376eac Merge pull request #483 from IsaacWise06/reorder-collections
feat(collections): Reorder top-level collections in the sidebar
2024-03-04 17:26:41 +03:30
daniel31x13 837241186f fully added subcollection tree functionality 2024-03-04 08:56:16 -05:00
daniel31x13 51cf8172ff minor fix 2024-03-02 09:07:33 -05:00
daniel31x13 9c51a65f31 improvements to the ui 2024-03-01 14:02:55 -05:00
daniel31x13 a451e9fa2e minor fix 2024-03-01 09:33:58 -05:00
daniel31x13 ba4860a910 small improvement 2024-03-01 08:37:20 -05:00
daniel31x13 84aeac96ce finished the collection listing ui 2024-03-01 06:59:14 -05:00
daniel31x13 ac70c9e29c refactored collection listing component [WIP] 2024-02-29 00:05:38 -05:00
daniel31x13 f77ef58396 Refactor collection selection component and update navbar styling 2024-02-28 10:33:53 -05:00
daniel31x13 4442ce8705 fix collectionOrder updating + remove index 2024-02-26 23:59:10 -05:00
daniel31x13 4ff7298a3b enable strict mode and fixing the Droppable issue 2024-02-26 22:29:23 -05:00
Daniel a8be4d8f2f Merge pull request #486 from linkwarden/feat/fix-export
bug fix
2024-02-23 20:41:58 +03:30
daniel31x13 f183f122e9 bug fix 2024-02-23 12:11:03 -05:00
Isaac Wise 5164f287d4 format 2024-02-22 03:15:14 -06:00
Isaac Wise 439c562002 Fix deleting all links in a collection with bulk action 2024-02-22 03:14:07 -06:00
Isaac Wise cc02ab3615 removed unused deps 2024-02-22 02:55:04 -06:00
Isaac Wise d2e59d48c2 format 2024-02-22 02:51:24 -06:00
Isaac Wise dbfdb587b6 fix reordering 2024-02-22 02:27:14 -06:00
Isaac Wise 7fd9f5b806 Update order when new collection is created 2024-02-22 02:24:10 -06:00
Isaac Wise 69ac3eb01f Use short-term storage as well 2024-02-22 02:04:01 -06:00
Isaac Wise 44272540aa Make sidebar collections sortable 2024-02-22 01:51:51 -06:00
daniel31x13 0dda77db1e minor improvement 2024-02-19 16:16:53 -05:00
Daniel 60aa7b830e Merge pull request #478 from go-compile/main
feat: Socks5 proxy support
2024-02-20 00:09:16 +03:30
daniel31x13 b6ad2b5900 final touch 2024-02-19 15:38:36 -05:00
daniel31x13 aee1828c15 swapped npm with yarn 2024-02-19 14:42:44 -05:00
Daniel 67bf6b7d75 Merge pull request #476 from IsaacWise06/collection-duplicate-names
feat(collections): Allow collections to be the same name
2024-02-19 23:07:38 +03:30
daniel31x13 bbc2e4c457 final touch 2024-02-19 14:37:07 -05:00
go-compile 1f28d9d461 fix: npm switch to yarn packages 2024-02-19 10:42:07 +00:00
Go Compile df1da9f1f8 Merge branch 'dev' into main 2024-02-18 22:02:35 +00:00
go-compile b476b3ccd4 feat: add deps socks-proxy-agent node-fetch deps for proxy 2024-02-18 21:43:53 +00:00
go-compile ae561ff227 feat: proxy archiver and pdf margin settings 2024-02-18 21:42:51 +00:00
Daniel d438381ebd Merge pull request #477 from linkwarden/fix/imports
Fix/imports
2024-02-18 19:38:31 +03:30
daniel31x13 72266d1cd5 final touch 2024-02-18 11:07:50 -05:00
Isaac Wise f560422427 Allow collections with the same name 2024-02-18 03:32:31 -06:00
daniel31x13 7b7b979b20 refactored import and add support for subcollections 2024-02-17 20:08:34 -05:00
Daniel c3c74b8162 Merge pull request #472 from IsaacWise06/fix/imports
Importing sub-collections fix
2024-02-18 04:33:28 +03:30
Isaac Wise 0e60dee47d Subcollections when importing 2024-02-15 11:26:42 -06:00
daniel31x13 c3f72c4be8 minor cleanup 2024-02-14 10:35:59 -05:00
Daniel 79bd95f650 Merge pull request #466 from IsaacWise06/issue-433
feat(links): Allow users to bulk edit/delete links
2024-02-14 16:41:05 +03:30
daniel31x13 88d73703f8 final touch 2024-02-14 08:10:45 -05:00
daniel31x13 41df9d0c82 minor improvement 2024-02-13 14:35:31 -05:00
daniel31x13 0b2e78332a improvements 2024-02-13 10:55:51 -05:00
Isaac Wise 558ba11db7 Merge branch 'dev' into issue-433 2024-02-13 09:18:11 -06:00
daniel31x13 155c77cbc4 final polishing 2024-02-13 05:54:18 -05:00
Isaac Wise a3c487d074 Don't show the edit button if the user can't edit/delete any links 2024-02-12 01:54:47 -06:00
Isaac Wise 1cff2db876 Fix redirect when there is no tag 2024-02-11 03:17:49 -06:00
Isaac Wise 2112176d6e Fixed disabled buttons 2024-02-11 02:49:27 -06:00
Isaac Wise aef33d859e make entire item clickable when in edit mode 2024-02-11 02:38:41 -06:00
Isaac Wise 5128bd44d8 keep edit buttons visible 2024-02-11 02:02:14 -06:00
Isaac Wise 0a77ee90a7 format 2024-02-11 01:29:11 -06:00
Isaac Wise e2c6993a6d Redirect if the tag does not exist 2024-02-11 01:26:44 -06:00
Isaac Wise e1c4a8575b Checkbox to remove previous tags 2024-02-11 01:21:25 -06:00
Isaac Wise 0c531760e8 Only show edit icon if there are links 2024-02-11 01:08:28 -06:00
Isaac Wise 5f468cd95d Add bulk actions to pinned and all links pages 2024-02-11 01:06:46 -06:00
Isaac Wise 63597a041f Fix sorting links when editing and handle not providing any data 2024-02-11 01:01:52 -06:00
Isaac Wise e753f1dded bulk actions on tags page 2024-02-11 00:19:59 -06:00
Isaac Wise 8ecedf7cae Edit Mode 2024-02-10 23:55:00 -06:00
Isaac Wise 44daffbae6 Use memoization for permission checking 2024-02-10 23:40:26 -06:00
Isaac Wise d5f262200b Get rid of unused import 2024-02-10 22:41:32 -06:00
Isaac Wise ccd3fcb8c1 revert some changes 2024-02-10 19:54:38 -06:00
Isaac Wise 059fcecc5f format 2024-02-10 18:34:25 -06:00
Isaac Wise 58e2fb22c9 Added comment about loop 2024-02-10 18:06:32 -06:00
Isaac Wise 2ace10c058 fix building again 2024-02-10 16:59:00 -06:00
Isaac Wise 4b8f4c4179 fix build error 2024-02-10 16:58:06 -06:00
Isaac Wise 8f62f4dffb Merge branch 'issue-433' of https://github.com/isaacwise06/linkwarden into issue-433 2024-02-10 16:49:37 -06:00
Isaac Wise 95dc3b31db Fix merge conflicts 2024-02-10 16:49:32 -06:00
Isaac Wise ebdeedc2ec Don't show select all if there are no links & fix public view 2024-02-10 16:45:25 -06:00
Isaac Wise 325c41254d center image 2024-02-10 16:35:58 -06:00
Isaac Wise fda782ec44 revert previous change 2024-02-10 16:25:38 -06:00
Isaac Wise 080be856cc Finished editing links 2024-02-10 16:23:59 -06:00
Isaac Wise e1ef638f0e Only show checkbox if the user has perms 2024-02-10 16:04:30 -06:00
Isaac Wise 582607e726 Pass the entire link to the store & fix bulk update function 2024-02-10 15:53:46 -06:00
Daniel 9eaa106766 Merge pull request #463 from IsaacWise06/fix-building
Fixed builds failing
2024-02-10 12:07:20 +03:30
Isaac Wise e0705ece4f Fixed builds failing 2024-02-10 02:34:52 -06:00
Isaac Wise da0533ac36 change routes and add todos 2024-02-10 02:29:15 -06:00
daniel31x13 e3d9912378 fixed the imports 2024-02-10 02:47:58 -05:00
Isaac Wise 26997475fd Fix permission checking 2024-02-10 01:38:19 -06:00
Isaac Wise ea31eb47ae Finished bulk delete links 2024-02-10 00:37:48 -06:00
Isaac Wise 193c66123b Don't show checkboxes on dashboard 2024-02-09 23:56:36 -06:00
Isaac Wise eba9d3c86d Display checkbox on card & reset when collection is changed 2024-02-09 23:51:02 -06:00
Isaac Wise b51355b406 Check all and display actions 2024-02-09 23:43:23 -06:00
Isaac Wise 0a070deebd Added selectedLinks to store & checkbox on list view 2024-02-09 23:24:22 -06:00
daniel31x13 c78aa2da0d minor improvement 2024-02-08 08:48:22 -05:00
Daniel aef55d65a1 Merge pull request #459 from IsaacWise06/issue/367
feat(links): Allow users to choose what happens when they click a link
2024-02-08 17:15:41 +03:30
daniel31x13 efddd55841 change the checkboxes to radio button 2024-02-08 08:45:14 -05:00
daniel31x13 f7a53d53e2 fix update collection bug 2024-02-08 08:25:45 -05:00
Isaac Wise ef08edf1fb Verify the preference is available 2024-02-08 00:59:17 -06:00
Isaac Wise 39261de45e rename function 2024-02-08 00:44:41 -06:00
Isaac Wise cc915c8a64 Allow users to choose what clicking links opens 2024-02-08 00:42:58 -06:00
daniel31x13 7d9cc1f1f0 added "linksRouteTo" field to the prisma schema 2024-02-07 10:30:09 -05:00
daniel31x13 b06cb7c379 merged the appearance and archive page into preference 2024-02-07 10:20:25 -05:00
Daniel d5bd095827 Merge pull request #456 from IsaacWise06/issue/334
feat(collections): Allow a contributor to pin a link from a collection to their dashboard
2024-02-07 18:19:15 +03:30
daniel31x13 daed2d82f4 minor improvements 2024-02-07 09:48:40 -05:00
Daniel 39e022f87b Merge pull request #457 from linkwarden/feat/sub-collections
Feat/sub collections
2024-02-07 01:17:09 +03:30
Isaac Wise 2d0093172a Allow contributors to pin a link in a shared to a collection to their dashboard 2024-02-04 23:43:59 -06:00
Daniel 34e0115a0f Merge pull request #445 from jan-tee/main
Added env var switch to support screen captures from HTTPS sites with untrusted certificates
2024-02-03 16:36:01 +03:30
Jan T ae3cf104b7 Added environment variable "IGNORE_SSL_ERRORS" to instruct playwright/Chromium to ignore SSL errors; this is useful to support generation of browser screenshots from sources with self-signed certificates or untrusted CAs, but also opens the possibility to index sites with rejected certificates; so it should not be enabled as a default behavior. 2024-01-29 09:49:50 +01:00
daniel31x13 047e156cfb updated version number 2024-01-17 13:02:44 -05:00
104 changed files with 4139 additions and 1284 deletions
+19
View File
@@ -20,6 +20,9 @@ MAX_LINKS_PER_USER=
ARCHIVE_TAKE_COUNT=
BROWSER_TIMEOUT=
IGNORE_UNAUTHORIZED_CA=
IGNORE_HTTPS_ERRORS=
IGNORE_URL_SIZE_LIMIT=
ADMINISTRATOR=
# AWS S3 Settings
SPACES_KEY=
@@ -34,6 +37,15 @@ NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM=
EMAIL_SERVER=
# Proxy settings
PROXY=
PROXY_USERNAME=
PROXY_PASSWORD=
PROXY_BYPASS=
# PDF archive settings
PDF_MARGIN_TOP=
PDF_MARGIN_BOTTOM=
#
# SSO Providers
@@ -65,6 +77,13 @@ AUTH0_ISSUER=
AUTH0_CLIENT_SECRET=
AUTH0_CLIENT_ID=
# Authelia
NEXT_PUBLIC_AUTHELIA_ENABLED=""
AUTHELIA_CLIENT_ID=""
AUTHELIA_CLIENT_SECRET=""
AUTHELIA_WELLKNOWN_URL=""
# Authentik
NEXT_PUBLIC_AUTHENTIK_ENABLED=
AUTHENTIK_CUSTOM_NAME=
+45
View File
@@ -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).
+1 -1
View File
@@ -20,4 +20,4 @@ COPY . .
RUN yarn prisma generate && \
yarn build
CMD yarn prisma migrate deploy && yarn start
CMD yarn prisma migrate deploy && yarn start
View File
+5 -1
View File
@@ -59,7 +59,7 @@ We've forked the old version from the current repository into [this repo](https:
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- 📂 Organize links by collection, name, description and multiple tags.
- 📂 Organize links by collection, sub-collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection.
- 🎛️ Customize the permissions of each member.
- 🌐 Share your collected links and preserved formats with the world.
@@ -70,6 +70,10 @@ We've forked the old version from the current repository into [this repo](https:
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- ✨ And so many more features!
## Like what we're doing? Give us a Star ⭐
+2 -2
View File
@@ -12,11 +12,11 @@ export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
<div className="w-fit font-semibold">
🎉 See what&apos;s new in{" "}
<Link
href="https://blog.linkwarden.app/releases/v2.4"
href="https://blog.linkwarden.app/releases/v2.5"
target="_blank"
className="underline hover:opacity-50 duration-100"
>
Linkwarden v2.4
Linkwarden v2.5
</Link>
! 🥳
</div>
+25 -6
View File
@@ -8,19 +8,38 @@ type Props = {
onMount?: (rect: DOMRect) => void;
};
function getZIndex(element: HTMLElement): number {
let zIndex = 0;
while (element) {
const zIndexStyle = window
.getComputedStyle(element)
.getPropertyValue("z-index");
const numericZIndex = Number(zIndexStyle);
if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) {
zIndex = numericZIndex;
break;
}
element = element.parentElement as HTMLElement;
}
return zIndex;
}
function useOutsideAlerter(
ref: RefObject<HTMLElement>,
onClickOutside: Function
) {
useEffect(() => {
function handleClickOutside(event: Event) {
if (
ref.current &&
!ref.current.contains(event.target as HTMLInputElement)
) {
onClickOutside(event);
function handleClickOutside(event: MouseEvent) {
const clickedElement = event.target as HTMLElement;
if (ref.current && !ref.current.contains(clickedElement)) {
const refZIndex = getZIndex(ref.current);
const clickedZIndex = getZIndex(clickedElement);
if (clickedZIndex <= refZIndex) {
onClickOutside(event);
}
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
+365
View File
@@ -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>&bull;</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 };
};
-160
View File
@@ -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>
);
};
+24 -22
View File
@@ -26,7 +26,7 @@ export default function FilterSearchDropdown({
>
<i className="bi-funnel text-neutral text-2xl"></i>
</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>
<label
className="label cursor-pointer flex justify-start"
@@ -84,27 +84,6 @@ export default function FilterSearchDropdown({
<span className="label-text">Description</span>
</label>
</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>
<label
className="label cursor-pointer flex justify-start"
@@ -126,6 +105,29 @@ export default function FilterSearchDropdown({
<span className="label-text">Tags</span>
</label>
</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>
</div>
);
+88 -15
View File
@@ -4,18 +4,26 @@ import { useEffect, useState } from "react";
import { styles } from "./styles";
import { Options } from "./types";
import CreatableSelect from "react-select/creatable";
import Select from "react-select";
type Props = {
onChange: any;
defaultValue:
showDefaultValue?: boolean;
defaultValue?:
| {
label: string;
value?: number;
}
| undefined;
creatable?: boolean;
};
export default function CollectionSelection({ onChange, defaultValue }: Props) {
export default function CollectionSelection({
onChange,
defaultValue,
showDefaultValue = true,
creatable = true,
}: Props) {
const { collections } = useCollectionStore();
const router = useRouter();
@@ -36,22 +44,87 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
useEffect(() => {
const formatedCollections = collections.map((e) => {
return { value: e.id, label: e.name, ownerId: e.ownerId };
return {
value: e.id,
label: e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
});
setOptions(formatedCollections);
}, [collections]);
return (
<CreatableSelect
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
defaultValue={defaultValue}
// menuPosition="fixed"
/>
);
const getParentNames = (parentId: number): string[] => {
const parentNames = [];
const parent = collections.find((e) => e.id === parentId);
if (parent) {
parentNames.push(parent.name);
if (parent.parentId) {
parentNames.push(...getParentNames(parent.parentId));
}
}
// Have the top level parent at beginning
return parentNames.reverse();
};
const customOption = ({ data, innerProps }: any) => {
return (
<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"
/>
);
}
}
+16
View File
@@ -1,10 +1,16 @@
import LinkCard from "@/components/LinkViews/LinkCard";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { link } from "fs";
import { GridLoader } from "react-spinners";
export default function CardView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
return (
<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}
count={i}
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>
);
}
+16 -1
View File
@@ -1,13 +1,18 @@
import LinkList from "@/components/LinkViews/LinkList";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
export default function ListView({
links,
editMode,
isLoading,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
}) {
return (
<div className="flex flex-col">
<div className="flex gap-1 flex-col">
{links.map((e, i) => {
return (
<LinkList
@@ -15,9 +20,19 @@ export default function ListView({
link={e}
count={i}
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>
);
}
+62 -35
View File
@@ -14,30 +14,49 @@ import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link";
import LinkIcon from "./LinkComponents/LinkIcon";
import LinkGroupedIconURL from "./LinkComponents/LinkGroupedIconURL";
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";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkGrid({
link,
count,
className,
flipDropdown,
}: Props) {
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
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;
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) {
console.log(error);
}
@@ -59,6 +78,7 @@ export default function LinkGrid({
const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
useEffect(() => {
let interval: any;
@@ -82,15 +102,35 @@ export default function LinkGrid({
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);
return (
<div
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
href={link.url || ""}
target="_blank"
<div
className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, account), "_blank")
}
>
<div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? (
@@ -112,15 +152,7 @@ export default function LinkGrid({
) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
<div
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"
>
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md">
<LinkIcon link={link} />
</div>
</div>
@@ -132,27 +164,22 @@ export default function LinkGrid({
{unescapeString(link.name || link.description) || link.url}
</p>
<div title={link.url || ""} className="w-fit">
<div className="flex gap-1 item-center select-none text-neutral mt-1">
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</div>
</div>
<LinkTypeBadge link={link} />
</div>
<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="cursor-pointer w-fit">
{collection ? (
{collection && (
<LinkCollection link={link} collection={collection} />
) : undefined}
)}
</div>
<LinkDate link={link} />
</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
onClick={() => setShowInfo(!showInfo)}
@@ -172,7 +199,7 @@ export default function LinkGrid({
</span>
)}
</p>
{link.tags[0] ? (
{link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
@@ -195,9 +222,9 @@ export default function LinkGrid({
</div>
</div>
</>
) : undefined}
)}
</div>
) : undefined}
)}
<LinkActions
link={link}
@@ -80,22 +80,20 @@ export default function LinkActions({
<i title="More" className="bi-three-dots text-xl" />
</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">
{permissions === true ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
pinLink();
}}
>
{link?.pinnedBy && link.pinnedBy[0]
? "Unpin"
: "Pin to Dashboard"}
</div>
</li>
) : undefined}
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
pinLink();
}}
>
{link?.pinnedBy && link.pinnedBy[0]
? "Unpin"
: "Pin to Dashboard"}
</div>
</li>
{linkInfo !== undefined && toggleShowInfo ? (
<li>
<div
@@ -124,18 +122,20 @@ export default function LinkActions({
</div>
</li>
) : undefined}
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true);
}}
>
Preserved Formats
</div>
</li>
{link.type === "url" && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true);
}}
>
Preserved Formats
</div>
</li>
)}
{permissions === true || permissions?.canDelete ? (
<li>
<div
@@ -2,6 +2,7 @@ import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
@@ -15,12 +16,12 @@ export default function LinkCollection({
const router = useRouter();
return (
<div
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.preventDefault();
router.push(`/collections/${link.collection.id}`);
e.stopPropagation();
}}
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}
>
<i
@@ -28,6 +29,6 @@ export default function LinkCollection({
style={{ color: collection?.color }}
></i>
<p className="truncate capitalize">{collection?.name}</p>
</div>
</Link>
);
}
@@ -6,14 +6,13 @@ export default function LinkDate({
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
const formattedDate = new Date(
(link.importDate || link.createdAt) as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<div className="flex items-center gap-1 text-neutral">
@@ -6,9 +6,11 @@ import React from "react";
export default function LinkIcon({
link,
width,
className,
}: {
link: LinkIncludingShortenedCollectionAndTags;
width?: string;
className?: string;
}) {
const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
@@ -16,33 +18,55 @@ export default function LinkIcon({
const iconClasses: string =
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
" " +
(width || "w-12");
(width || "w-12") +
" " +
(className || "");
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return (
<>
{link.url && url && showFavicon ? (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64}
height={64}
alt=""
className={iconClasses}
draggable="false"
onError={() => {
setShowFavicon(false);
}}
/>
) : showFavicon === false ? (
<div className={iconClasses}>
<i className="bi-link-45deg text-4xl text-black"></i>
</div>
{link.type === "url" && url ? (
showFavicon ? (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64}
height={64}
alt=""
className={iconClasses}
draggable="false"
onError={() => {
setShowFavicon(false);
}}
/>
) : (
<LinkPlaceholderIcon iconClasses={iconClasses} icon="bi-link-45deg" />
)
) : link.type === "pdf" ? (
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? (
<i className={`bi-file-earmark-image ${iconClasses}`}></i>
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-image"
/>
) : undefined}
</>
);
}
const LinkPlaceholderIcon = ({
iconClasses,
icon,
}: {
iconClasses: string;
icon: string;
}) => {
return (
<div className={`text-4xl text-black aspect-square ${iconClasses}`}>
<i className={`${icon} m-auto`}></i>
</div>
);
};
@@ -0,0 +1,38 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
let shortendURL;
if (link.type === "url" && link.url) {
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
}
}
return link.url && shortendURL ? (
<Link
href={link.url || ""}
target="_blank"
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 select-none">
{link.type}
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ type Props = {
className?: string;
};
export default function LinkGrid({ link, count, className }: Props) {
export default function LinkGrid({ link }: Props) {
const { collections } = useCollectionStore();
const { links } = useLinkStore();
+77 -73
View File
@@ -12,31 +12,50 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import Link from "next/link";
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";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCardCompact({
link,
count,
className,
flipDropdown,
editMode,
}: Props) {
const { collections } = useCollectionStore();
const { account } = useAccountStore();
const { links, setSelectedLinks, selectedLinks } = useLinkStore();
const { links } = useLinkStore();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
let shortendURL;
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
const linkIndex = selectedLinks.findIndex(
(selectedLink) => selectedLink.id === link.id
);
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
if (linkIndex !== -1) {
const updatedLinks = [...selectedLinks];
updatedLinks.splice(linkIndex, 1);
setSelectedLinks(updatedLinks);
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
@@ -53,26 +72,66 @@ export default function LinkCardCompact({
);
}, [collections, links]);
const permissions = usePermissions(collection?.id as number);
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 (
<>
<div
className={`border-neutral-content relative ${
className={`${selectedStyle} border relative items-center flex ${
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
} duration-200 rounded-lg`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(
"You don't have permission to edit or delete this item."
)
: undefined
}
>
<Link
href={link.url || ""}
target="_blank"
className="flex items-start cursor-pointer"
{/* {showCheckbox &&
editMode &&
(permissions === true ||
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">
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
<LinkIcon
link={link}
width="sm:w-12 w-8"
className="mt-1 sm:mt-0"
/>
</div>
<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}
</p>
@@ -81,22 +140,12 @@ export default function LinkCardCompact({
{collection ? (
<LinkCollection link={link} collection={collection} />
) : undefined}
{link.url ? (
<div className="flex items-center gap-1 w-fit text-neutral truncate">
<i className="bi-link-45deg text-lg" />
<p className="truncate w-full">{shortendURL}</p>
</div>
) : (
<div className="badge badge-primary badge-sm my-1">
{link.type}
</div>
)}
<LinkTypeBadge link={link} />
<LinkDate link={link} />
</div>
</div>
</div>
</Link>
</div>
<LinkActions
link={link}
collection={collection}
@@ -105,52 +154,7 @@ export default function LinkCardCompact({
// toggleShowInfo={() => setShowInfo(!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 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
&apos;Delete&apos; 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);
const { removeLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
@@ -0,0 +1,51 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
type Props = {
onClose: Function;
userId: number;
};
export default function DeleteUserModal({ onClose, userId }: Props) {
const { removeUser } = useUserStore();
const deleteUser = async () => {
const load = toast.loading("Deleting...");
const response = await removeUser(userId);
toast.dismiss(load);
response.ok && toast.success(`User Deleted.`);
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Delete User</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>Are you sure you want to remove this user?</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>
<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={deleteUser}
>
<i className="bi-trash text-xl" />
Delete, I know what I&apos;m doing
</button>
</div>
</Modal>
);
}
@@ -234,11 +234,8 @@ export default function EditCollectionSharingModal({
: undefined;
return (
<>
<div
key={i}
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
>
<React.Fragment key={i}>
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none">
<div
className={"flex items-center justify-between w-full"}
>
@@ -433,7 +430,7 @@ export default function EditCollectionSharingModal({
</div>
</div>
<div className="divider my-0 last:hidden h-[3px]"></div>
</>
</React.Fragment>
);
})}
</div>
@@ -124,6 +124,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
label: "Unorganized",
}
}
creatable={false}
/>
) : null}
</div>
@@ -6,6 +6,8 @@ import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import useAccountStore from "@/store/account";
import { useSession } from "next-auth/react";
type Props = {
onClose: Function;
@@ -21,6 +23,8 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
} as Partial<Collection>;
const [collection, setCollection] = useState<Partial<Collection>>(initial);
const { setAccount } = useAccountStore();
const { data } = useSession();
useEffect(() => {
setCollection(initial);
@@ -42,7 +46,11 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
if (response.ok) {
toast.success("Created!");
onClose();
if (response.data) {
// If the collection was created successfully, we need to get the new collection order
setAccount(data?.user.id as number);
onClose();
}
} else toast.error(response.data as string);
setSubmitLoader(false);
-1
View File
@@ -109,7 +109,6 @@ export default function NewLinkModal({ onClose }: Props) {
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
+133
View File
@@ -0,0 +1,133 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import useUserStore from "@/store/admin/users";
import TextInput from "../TextInput";
import { FormEvent, useState } from "react";
type Props = {
onClose: Function;
};
type FormData = {
name: string;
username?: string;
email?: string;
password: string;
};
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function NewUserModal({ onClose }: Props) {
const { addUser } = useUserStore();
const [form, setForm] = useState<FormData>({
name: "",
username: "",
email: emailEnabled ? "" : undefined,
password: "",
});
const [submitLoader, setSubmitLoader] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!submitLoader) {
const checkFields = () => {
if (emailEnabled) {
return form.name !== "" && form.email !== "" && form.password !== "";
} else {
return (
form.name !== "" && form.username !== "" && form.password !== ""
);
}
};
if (checkFields()) {
if (form.password.length < 8)
return toast.error("Passwords must be at least 8 characters.");
setSubmitLoader(true);
const load = toast.loading("Creating Account...");
const response = await addUser(form);
toast.dismiss(load);
setSubmitLoader(false);
if (response.ok) {
toast.success("User Created!");
onClose();
} else {
toast.error(response.data as string);
}
} else {
toast.error("Please fill out all the fields.");
}
}
}
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create New User</p>
<div className="divider mb-3 mt-1"></div>
<form onSubmit={submit}>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Display Name</p>
<TextInput
placeholder="Johnny"
className="bg-base-200"
onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.name}
/>
</div>
{emailEnabled ? (
<div>
<p className="mb-2">Username</p>
<TextInput
placeholder="john"
className="bg-base-200"
onChange={(e) => setForm({ ...form, username: e.target.value })}
value={form.username}
/>
</div>
) : undefined}
<div>
<p className="mb-2">Email</p>
<TextInput
placeholder="johnny@example.com"
className="bg-base-200"
onChange={(e) => setForm({ ...form, email: e.target.value })}
value={form.email}
/>
</div>
<div>
<p className="mb-2">Password</p>
<TextInput
placeholder="••••••••••••••"
className="bg-base-200"
onChange={(e) => setForm({ ...form, password: e.target.value })}
value={form.password}
/>
</div>
</div>
<div className="flex justify-between items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
type="submit"
>
Create User
</button>
</div>
</form>
</Modal>
);
}
+12 -46
View File
@@ -43,7 +43,7 @@ export default function UploadFileModal({ onClose }: Props) {
const [file, setFile] = useState<File>();
const { addLink } = useLinkStore();
const { uploadFile } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
@@ -100,56 +100,22 @@ export default function UploadFileModal({ onClose }: Props) {
const submit = async () => {
if (!submitLoader && file) {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
setSubmitLoader(true);
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
}
const load = toast.loading("Creating...");
if (fileType !== null && linkType !== null) {
setSubmitLoader(true);
const response = await uploadFile(link, file);
let response;
toast.dismiss(load);
const load = toast.loading("Creating...");
if (response.ok) {
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
response = await addLink({
...link,
type: linkType,
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
});
setSubmitLoader(false);
toast.dismiss(load);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${
(response.data as LinkIncludingShortenedCollectionAndTags).id
}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
return response;
}
};
@@ -238,7 +204,7 @@ export default function UploadFileModal({ onClose }: Props) {
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
Create Link
Upload File
</button>
</div>
</Modal>
+5 -69
View File
@@ -1,40 +1,24 @@
import { signOut } from "next-auth/react";
import { useEffect, useState } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import Sidebar from "@/components/Sidebar";
import { useRouter } from "next/router";
import SearchBar from "@/components/SearchBar";
import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import ToggleDarkMode from "./ToggleDarkMode";
import useLocalSettingsStore from "@/store/localSettings";
import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import Link from "next/link";
import UploadFileModal from "./ModalContent/UploadFileModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import MobileNavigation from "./MobileNavigation";
import ProfileDropdown from "./ProfileDropdown";
export default function Navbar() {
const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore();
const router = useRouter();
const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions();
const handleToggle = () => {
if (settings.theme === "dark") {
updateSettings({ theme: "light" });
} else {
updateSettings({ theme: "dark" });
}
};
useEffect(() => {
setSidebar(false);
document.body.style.overflow = "auto";
@@ -56,7 +40,7 @@ export default function Navbar() {
setSidebar(true);
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>
</div>
@@ -93,7 +77,7 @@ export default function Navbar() {
New Link
</div>
</li>
{/* <li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
@@ -104,7 +88,7 @@ export default function Navbar() {
>
Upload File
</div>
</li> */}
</li>
<li>
<div
onClick={() => {
@@ -120,55 +104,7 @@ export default function Navbar() {
</ul>
</div>
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-circle btn-ghost"
>
<ProfilePhoto
src={account.image ? account.image : undefined}
priority={true}
/>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
>
Settings
</Link>
</li>
<li className="block sm:hidden">
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
>
Switch to {settings.theme === "light" ? "Dark" : "Light"}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
Logout
</div>
</li>
</ul>
</div>
<ProfileDropdown />
</div>
<MobileNavigation />
+71
View File
@@ -0,0 +1,71 @@
import useLocalSettingsStore from "@/store/localSettings";
import { dropdownTriggerer } from "@/lib/client/utils";
import ProfilePhoto from "./ProfilePhoto";
import useAccountStore from "@/store/account";
import Link from "next/link";
import { signOut } from "next-auth/react";
export default function ProfileDropdown() {
const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore();
const handleToggle = () => {
if (settings.theme === "dark") {
updateSettings({ theme: "light" });
} else {
updateSettings({ theme: "dark" });
}
};
return (
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-circle btn-ghost"
>
<ProfilePhoto
src={account.image ? account.image : undefined}
priority={true}
/>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
>
Settings
</Link>
</li>
<li className="block sm:hidden">
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
>
Switch to {settings.theme === "light" ? "Dark" : "Light"}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
Logout
</div>
</li>
</ul>
</div>
);
}
+6 -2
View File
@@ -34,6 +34,8 @@ export default function ReadableView({ link }: Props) {
const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const [date, setDate] = useState<Date | string>();
const colorThief = new ColorThief();
const router = useRouter();
@@ -54,6 +56,8 @@ export default function ReadableView({ link }: Props) {
};
fetchLinkContent();
setDate(link.importDate || link.createdAt);
}, [link]);
useEffect(() => {
@@ -211,8 +215,8 @@ export default function ReadableView({ link }: Props) {
</div>
<p className="min-w-fit text-sm text-neutral">
{link?.createdAt
? new Date(link?.createdAt).toLocaleString("en-US", {
{date
? new Date(date).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
+5 -18
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.4.8";
const LINKWARDEN_VERSION = process.env.version;
const { collections } = useCollectionStore();
@@ -37,30 +37,17 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div>
</Link>
<Link href="/settings/appearance">
<Link href="/settings/preference">
<div
className={`${
active === `/settings/appearance`
active === `/settings/preference`
? "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-palette text-primary text-2xl"></i>
<i className="bi-sliders text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Appearance</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>
<p className="truncate w-full pr-7">Preference</p>
</div>
</Link>
+3 -4
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Disclosure, Transition } from "@headlessui/react";
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
import CollectionSelection from "@/components/CollectionSelection";
import CollectionListing from "@/components/CollectionListing";
export default function Sidebar({ className }: { className?: string }) {
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
@@ -22,11 +22,10 @@ export default function Sidebar({ className }: { className?: string }) {
const { collections } = useCollectionStore();
const { tags } = useTagStore();
const [active, setActive] = useState("");
const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => {
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
}, [tagDisclosure]);
@@ -99,7 +98,7 @@ export default function Sidebar({ className }: { className?: string }) {
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel>
<CollectionSelection links={true} />
<CollectionListing />
</Disclosure.Panel>
</Transition>
</Disclosure>
+1 -1
View File
@@ -14,7 +14,7 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
tabIndex={0}
role="button"
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>
</div>
+1 -1
View File
@@ -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 { ViewMode } from "@/types/global";
+34
View File
@@ -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
View File
@@ -1,5 +1,5 @@
import { LinkRequestQuery } from "@/types/global";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import useDetectPageBottom from "./useDetectPageBottom";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
@@ -18,9 +18,12 @@ export default function useLinks(
searchByTextContent,
}: LinkRequestQuery = { sort: 0 }
) {
const { links, setLinks, resetLinks } = useLinkStore();
const { links, setLinks, resetLinks, selectedLinks, setSelectedLinks } =
useLinkStore();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
@@ -60,16 +63,24 @@ export default function useLinks(
basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links";
setIsLoading(true);
const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json();
setIsLoading(false);
if (response.ok) setLinks(data.response, isInitialCall);
};
useEffect(() => {
// Save the selected links before resetting the links
// and then restore the selected links after resetting the links
const previouslySelected = selectedLinks;
resetLinks();
setSelectedLinks(previouslySelected);
getLinks(true);
}, [
router,
@@ -87,4 +98,6 @@ export default function useLinks(
setReachedBottom(false);
}, [reachedBottom]);
return { isLoading };
}
+44 -50
View File
@@ -1,4 +1,4 @@
import { chromium, devices } from "playwright";
import { LaunchOptions, chromium, devices } from "playwright";
import { prisma } from "./db";
import createFile from "./storage/createFile";
import sendToWayback from "./sendToWayback";
@@ -7,9 +7,9 @@ import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";
import { Collection, Link, User } from "@prisma/client";
import validateUrlSize from "./validateUrlSize";
import removeFile from "./storage/removeFile";
import Jimp from "jimp";
import createFolder from "./storage/createFolder";
import generatePreview from "./generatePreview";
import { removeFiles } from "./manageLinkFiles";
type LinksAndCollectionAndOwner = Link & {
collection: Collection & {
@@ -20,8 +20,23 @@ type LinksAndCollectionAndOwner = Link & {
const BROWSER_TIMEOUT = Number(process.env.BROWSER_TIMEOUT) || 5;
export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
const browser = await chromium.launch();
const context = await browser.newContext(devices["Desktop Chrome"]);
// allow user to configure a proxy
let browserOptions: LaunchOptions = {};
if (process.env.PROXY) {
browserOptions.proxy = {
server: process.env.PROXY,
bypass: process.env.PROXY_BYPASS,
username: process.env.PROXY_USERNAME,
password: process.env.PROXY_PASSWORD,
};
}
const browser = await chromium.launch(browserOptions);
const context = await browser.newContext({
...devices["Desktop Chrome"],
ignoreHTTPSErrors: process.env.IGNORE_HTTPS_ERRORS === "true",
});
const page = await context.newPage();
const timeoutPromise = new Promise((_, reject) => {
@@ -36,6 +51,14 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
);
});
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
createFolder({
filePath: `archives/${link.collectionId}`,
});
try {
await Promise.race([
(async () => {
@@ -43,7 +66,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
? await validateUrlSize(link.url)
: undefined;
if (validatedUrl === null)
if (
validatedUrl === null &&
process.env.IGNORE_URL_SIZE_LIMIT !== "true"
)
throw "Something went wrong while retrieving the file size.";
const contentType = validatedUrl?.get("content-type");
@@ -70,11 +96,11 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
image:
user.archiveAsScreenshot && !link.image?.startsWith("archive")
? "pending"
: undefined,
: "unavailable",
pdf:
user.archiveAsPDF && !link.pdf?.startsWith("archive")
? "pending"
: undefined,
: "unavailable",
readable: !link.readable?.startsWith("archive")
? "pending"
: undefined,
@@ -147,10 +173,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
return metaTag ? (metaTag as any).content : null;
});
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
if (ogImageUrl) {
console.log("Found og:image URL:", ogImageUrl);
@@ -160,35 +182,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
// Check if imageResponse is not null
if (imageResponse && !link.preview?.startsWith("archive")) {
const buffer = await imageResponse.body();
// Check if buffer is not null
if (buffer) {
// Load the image using Jimp
Jimp.read(buffer, async (err, image) => {
if (image && !err) {
image?.resize(1280, Jimp.AUTO).quality(20);
const processedBuffer = await image?.getBufferAsync(
Jimp.MIME_JPEG
);
createFile({
data: processedBuffer,
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
}).then(() => {
return prisma.link.update({
where: { id: link.id },
data: {
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
},
});
});
}
}).catch((err) => {
console.error("Error processing the image:", err);
});
} else {
console.log("No image data found.");
}
await generatePreview(buffer, link.collectionId, link.id);
}
await page.goBack();
@@ -238,6 +232,13 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
})
);
}
// apply administrator's defined pdf margins or default to 15px
const margins = {
top: process.env.PDF_MARGIN_TOP || "15px",
bottom: process.env.PDF_MARGIN_BOTTOM || "15px",
};
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
processingPromises.push(
page
@@ -245,7 +246,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
width: "1366px",
height: "1931px",
printBackground: true,
margin: { top: "15px", bottom: "15px" },
margin: margins,
})
.then((pdf) => {
return createFile({
@@ -301,14 +302,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
},
});
else {
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
removeFile({
filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
});
removeFile({
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
});
await removeFiles(link.id, link.collectionId);
}
await browser.close();
+7 -6
View File
@@ -32,11 +32,12 @@ export default async function checkSubscriptionByEmail(email: string) {
customer.subscriptions?.data.some((subscription) => {
subscription.current_period_end;
active = subscription.items.data.some(
(e) =>
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
);
active =
subscription.items.data.some(
(e) =>
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
) || false;
stripeSubscriptionId = subscription.id;
currentPeriodStart = subscription.current_period_start * 1000;
currentPeriodEnd = subscription.current_period_end * 1000;
@@ -44,7 +45,7 @@ export default async function checkSubscriptionByEmail(email: string) {
});
return {
active,
active: active || false,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
@@ -31,6 +31,8 @@ export default async function deleteCollection(
},
});
await removeFromOrders(userId, collectionId);
return { response: deletedUsersAndCollectionsRelation, status: 200 };
} else if (collectionIsAccessible?.ownerId !== userId) {
return { response: "Collection is not accessible.", status: 401 };
@@ -57,6 +59,8 @@ export default async function deleteCollection(
await removeFolder({ filePath: `archives/${collectionId}` });
await removeFromOrders(userId, collectionId);
return await prisma.collection.delete({
where: {
id: collectionId,
@@ -98,3 +102,28 @@ async function deleteSubCollections(collectionId: number) {
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))
return { response: "Collection is not accessible.", status: 401 };
if (data.parentId) {
const findParentCollection = await prisma.collection.findUnique({
where: {
id: data.parentId,
},
select: {
ownerId: true,
},
});
console.log(data);
if (
findParentCollection?.ownerId !== userId ||
typeof data.parentId !== "number"
)
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
if (data.parentId) {
if (data.parentId !== ("root" as any)) {
const findParentCollection = await prisma.collection.findUnique({
where: {
id: data.parentId,
},
select: {
ownerId: true,
parentId: true,
},
});
if (
findParentCollection?.ownerId !== userId ||
typeof data.parentId !== "number" ||
findParentCollection?.parentId === data.parentId
)
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
}
}
const updatedCollection = await prisma.$transaction(async () => {
@@ -51,17 +57,23 @@ export default async function updateCollection(
where: {
id: collectionId,
},
data: {
name: data.name.trim(),
description: data.description,
color: data.color,
isPublic: data.isPublic,
parent: {
connect: {
id: data.parentId || undefined,
},
},
parent:
data.parentId && data.parentId !== ("root" as any)
? {
connect: {
id: data.parentId,
},
}
: data.parentId === ("root" as any)
? {
disconnect: true,
}
: undefined,
members: {
create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } },
@@ -12,6 +12,12 @@ export default async function getCollection(userId: number) {
_count: {
select: { links: true },
},
parent: {
select: {
id: true,
name: true,
},
},
members: {
include: {
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({
data: {
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}` });
return { response: newCollection, status: 200 };
@@ -0,0 +1,51 @@
import { prisma } from "@/lib/api/db";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
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];
if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id);
}
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,7 +1,8 @@
import { prisma } from "@/lib/api/db";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import { Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
@@ -12,7 +13,10 @@ export default async function deleteLink(userId: number, linkId: number) {
(e: UsersAndCollections) => e.userId === userId && e.canDelete
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
if (
!collectionIsAccessible ||
!(collectionIsAccessible?.ownerId === userId || memberHasAccess)
)
return { response: "Collection is not accessible.", status: 401 };
const deleteLink: Link = await prisma.link.delete({
@@ -21,15 +25,7 @@ export default async function deleteLink(userId: number, linkId: number) {
},
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
removeFiles(linkId, collectionIsAccessible.id);
return { response: deleteLink, status: 200 };
}
@@ -1,8 +1,8 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import moveFile from "@/lib/api/storage/moveFile";
import { moveFiles } from "@/lib/api/manageLinkFiles";
export default async function updateLinkById(
userId: number,
@@ -17,13 +17,70 @@ export default async function updateLinkById(
const collectionIsAccessible = await getPermission({ userId, linkId });
const isCollectionOwner =
collectionIsAccessible?.ownerId === data.collection.ownerId &&
data.collection.ownerId === userId;
const canPinPermission = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
);
// If the user is able to create a link, they can pin it to their dashboard only.
if (canPinPermission) {
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
? { connect: { id: userId } }
: { disconnect: { id: userId } },
},
include: {
collection: true,
pinnedBy: isCollectionOwner
? {
where: { id: userId },
select: { id: true },
}
: undefined,
},
});
return { response: updatedLink, status: 200 };
}
const targetCollectionIsAccessible = await getPermission({
userId,
collectionId: data.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
);
const isCollectionOwner =
collectionIsAccessible?.ownerId === data.collection.ownerId &&
data.collection.ownerId === userId;
const targetCollectionsAccessible =
targetCollectionIsAccessible?.ownerId === userId;
const targetCollectionMatchesData = data.collection.id
? data.collection.id === targetCollectionIsAccessible?.id
: true && data.collection.name
? data.collection.name === targetCollectionIsAccessible?.name
: true && data.collection.ownerId
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
: true;
if (!targetCollectionsAccessible)
return {
response: "Target collection is not accessible.",
status: 401,
};
else if (!targetCollectionMatchesData)
return {
response: "Target collection does not match the data.",
status: 401,
};
const unauthorizedSwitchCollection =
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
@@ -89,20 +146,7 @@ export default async function updateLinkById(
});
if (collectionIsAccessible?.id !== data.collection.id) {
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
`archives/${data.collection.id}/${linkId}.pdf`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}.png`,
`archives/${data.collection.id}/${linkId}.png`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
`archives/${data.collection.id}/${linkId}_readability.json`
);
await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id);
}
return { response: updatedLink, status: 200 };
+130 -37
View File
@@ -12,18 +12,136 @@ export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags,
userId: number
) {
try {
new URL(link.url || "");
} catch (error) {
return {
response:
"Please enter a valid Address for the Link. (It should start with http/https)",
status: 400,
};
if (link.url || link.type === "url") {
try {
new URL(link.url || "");
} catch (error) {
return {
response:
"Please enter a valid Address for the Link. (It should start with http/https)",
status: 400,
};
}
}
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.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 url = link.url?.trim().replace(/\/+$/, ""); // trim and remove trailing slashes from the URL
const hasWwwPrefix = url?.includes(`://www.`);
const urlWithoutWww = hasWwwPrefix ? url?.replace(`://www.`, "://") : url;
const urlWithWww = hasWwwPrefix ? url : url?.replace("://", `://www.`);
console.log(url, urlWithoutWww, urlWithWww);
const existingLink = await prisma.link.findFirst({
where: {
OR: [{ url: urlWithWww }, { url: urlWithoutWww }],
collection: {
ownerId: userId,
},
},
});
console.log(url, urlWithoutWww, urlWithWww, "DONE!");
if (existingLink)
return {
response: "Link already exists",
status: 409,
};
}
const numberOfLinksTheUserHas = await prisma.link.count({
@@ -42,22 +160,6 @@ export default async function postLink(
link.collection.name = link.collection.name.trim();
if (link.collection.id) {
const collectionIsAccessible = await getPermission({
userId,
collectionId: link.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
} else {
link.collection.ownerId = userId;
}
const description =
link.description && link.description !== ""
? link.description
@@ -81,22 +183,13 @@ export default async function postLink(
const newLink = await prisma.link.create({
data: {
url: link.url,
url: link.url?.trim().replace(/\/+$/, "") || null,
name: link.name,
description,
type: linkType,
collection: {
connectOrCreate: {
where: {
name_ownerId: {
ownerId: link.collection.ownerId,
name: link.collection.name,
},
},
create: {
name: link.collection.name.trim(),
ownerId: userId,
},
connect: {
id: link.collection.id,
},
},
tags: {
@@ -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 createFolder from "@/lib/api/storage/createFolder";
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;
@@ -11,6 +13,11 @@ export default async function importFromHTMLFile(
const dom = new JSDOM(rawData);
const document = dom.window.document;
// remove bad tags
document.querySelectorAll("meta").forEach((e) => (e.outerHTML = e.innerHTML));
document.querySelectorAll("META").forEach((e) => (e.outerHTML = e.innerHTML));
document.querySelectorAll("P").forEach((e) => (e.outerHTML = e.innerHTML));
const bookmarks = document.querySelectorAll("A");
const totalImports = bookmarks.length;
@@ -28,94 +35,232 @@ export default async function importFromHTMLFile(
status: 400,
};
const folders = document.querySelectorAll("H3");
const jsonData = parse(document.documentElement.outerHTML);
await prisma
.$transaction(
async () => {
// @ts-ignore
for (const folder of folders) {
const findCollection = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collections: {
where: {
name: folder.textContent.trim(),
},
},
},
});
const processedArray = processNodes(jsonData);
const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists || !collectionId) {
const newCollection = await prisma.collection.create({
data: {
name: folder.textContent.trim(),
description: "",
color: "#0ea5e9",
isPublic: false,
ownerId: userId,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
createFolder({ filePath: `archives/${collectionId}` });
const bookmarks = folder.nextElementSibling.querySelectorAll("A");
for (const bookmark of bookmarks) {
await prisma.link.create({
data: {
name: bookmark.textContent.trim(),
url: bookmark.getAttribute("HREF"),
tags: bookmark.getAttribute("TAGS")
? {
connectOrCreate: bookmark
.getAttribute("TAGS")
.split(",")
.map((tag: string) =>
tag
? {
where: {
name_ownerId: {
name: tag.trim(),
ownerId: userId,
},
},
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
},
},
},
}
: undefined
),
}
: undefined,
description: bookmark.getAttribute("DESCRIPTION")
? bookmark.getAttribute("DESCRIPTION")
: "",
collectionId: collectionId,
createdAt: new Date(),
},
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
for (const item of processedArray) {
console.log(item);
await processBookmarks(userId, item as Element);
}
return { response: "Success.", status: 200 };
}
async function processBookmarks(
userId: number,
data: Node,
parentCollectionId?: number
) {
if (data.type === "element") {
for (const item of data.children) {
if (item.type === "element" && item.tagName === "dt") {
// process collection or sub-collection
let collectionId;
const collectionName = item.children.find(
(e) => e.type === "element" && e.tagName === "h3"
) as Element;
if (collectionName) {
collectionId = await createCollection(
userId,
(collectionName.children[0] as TextNode).content,
parentCollectionId
);
}
await processBookmarks(
userId,
item,
collectionId || parentCollectionId
);
} else if (item.type === "element" && item.tagName === "a") {
// process link
const linkUrl = item?.attributes.find(
(e) => e.key.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) {
e.name = e.name.trim();
const findCollection = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collections: {
where: {
name: e.name,
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists) {
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
createFolder({ filePath: `archives/${newCollection.id}` });
// Import Links
for (const link of e.links) {
@@ -82,7 +61,7 @@ export default async function importFromLinkwarden(
description: link.description,
collection: {
connect: {
id: collectionId,
id: newCollection.id,
},
},
// Import Tags
+21
View File
@@ -0,0 +1,21 @@
import { prisma } from "@/lib/api/db";
export default async function getUsers() {
// Get all users
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
emailVerified: true,
subscriptions: {
select: {
active: true,
},
},
createdAt: true,
},
});
return { response: users, status: 200 };
}
+72 -17
View File
@@ -1,9 +1,11 @@
import { prisma } from "@/lib/api/db";
import type { NextApiRequest, NextApiResponse } from "next";
import bcrypt from "bcrypt";
import verifyUser from "../../verifyUser";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false;
interface Data {
response: string | object;
@@ -20,7 +22,15 @@ export default async function postUser(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
let isServerAdmin = false;
const user = await verifyUser({ req, res });
if (process.env.ADMINISTRATOR === user?.username) isServerAdmin = true;
if (
process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" &&
!isServerAdmin
) {
return res.status(400).json({ response: "Registration is disabled." });
}
@@ -57,13 +67,16 @@ export default async function postUser(
});
const checkIfUserExists = await prisma.user.findFirst({
where: emailEnabled
? {
where: {
OR: [
{
email: body.email?.toLowerCase().trim(),
}
: {
},
{
username: (body.username as string).toLowerCase().trim(),
},
],
},
});
if (!checkIfUserExists) {
@@ -71,21 +84,63 @@ export default async function postUser(
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
await prisma.user.create({
data: {
name: body.name,
username: emailEnabled
? undefined
: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
},
});
// Subscription dates
const currentPeriodStart = new Date();
const currentPeriodEnd = new Date();
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
return res.status(201).json({ response: "User successfully created." });
if (isServerAdmin) {
const user = await prisma.user.create({
data: {
name: body.name,
username: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
emailVerified: new Date(),
subscriptions: stripeEnabled
? {
create: {
stripeSubscriptionId:
"fake_sub_" + Math.round(Math.random() * 10000000000000),
active: true,
currentPeriodStart,
currentPeriodEnd,
},
}
: undefined,
},
select: {
id: true,
username: true,
email: true,
emailVerified: true,
subscriptions: {
select: {
active: true,
},
},
createdAt: true,
},
});
return res.status(201).json({ response: user });
} else {
await prisma.user.create({
data: {
name: body.name,
username: emailEnabled
? undefined
: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
},
});
return res.status(201).json({ response: "User successfully created." });
}
} else if (checkIfUserExists) {
return res.status(400).json({
response: `${emailEnabled ? "Email" : "Username"} already exists.`,
response: `Email or Username already exists.`,
});
}
}
@@ -10,7 +10,8 @@ const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET;
export default async function deleteUserById(
userId: number,
body: DeleteUserBody
body: DeleteUserBody,
isServerAdmin?: boolean
) {
// First, we retrieve the user from the database
const user = await prisma.user.findUnique({
@@ -25,13 +26,13 @@ export default async function deleteUserById(
}
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
if (!keycloakEnabled && !authentikEnabled) {
if (!keycloakEnabled && !authentikEnabled && !isServerAdmin) {
const isPasswordValid = bcrypt.compareSync(
body.password,
user.password as string
);
if (!isPasswordValid) {
if (!isPasswordValid && !isServerAdmin) {
return {
response: "Invalid credentials.",
status: 401, // Unauthorized
@@ -43,6 +44,11 @@ export default async function deleteUserById(
await prisma
.$transaction(
async (prisma) => {
// Delete Access Tokens
await prisma.accessToken.deleteMany({
where: { userId },
});
// Delete whitelisted users
await prisma.whitelistedUser.deleteMany({
where: { userId },
@@ -71,6 +77,10 @@ export default async function deleteUserById(
// Delete archive folders
removeFolder({ filePath: `archives/${collection.id}` });
await removeFolder({
filePath: `archives/preview/${collection.id}`,
});
}
// Delete collections after cleaning up related data
@@ -83,6 +93,7 @@ export default async function deleteUserById(
await prisma.subscription.delete({
where: { userId },
});
// .catch((err) => console.log(err));
await prisma.usersAndCollections.deleteMany({
where: {
@@ -183,9 +183,14 @@ export default async function updateUserById(
email: data.email?.toLowerCase().trim(),
isPrivate: data.isPrivate,
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
collectionOrder: data.collectionOrder.filter(
(value, index, self) => self.indexOf(value) === index
),
archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
linksRouteTo: data.linksRouteTo,
preventDuplicateLinks: data.preventDuplicateLinks,
password:
data.newPassword && data.newPassword !== ""
? newHashedPassword
+36
View File
@@ -0,0 +1,36 @@
import Jimp from "jimp";
import { prisma } from "./db";
import createFile from "./storage/createFile";
import createFolder from "./storage/createFolder";
const generatePreview = async (
buffer: Buffer,
collectionId: number,
linkId: number
) => {
if (buffer && collectionId && linkId) {
// Load the image using Jimp
await Jimp.read(buffer, async (err, image) => {
if (image && !err) {
image?.resize(1280, Jimp.AUTO).quality(20);
const processedBuffer = await image?.getBufferAsync(Jimp.MIME_JPEG);
createFile({
data: processedBuffer,
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
}).then(() => {
return prisma.link.update({
where: { id: linkId },
data: {
preview: `archives/preview/${collectionId}/${linkId}.jpeg`,
},
});
});
}
}).catch((err) => {
console.error("Error processing the image:", err);
});
}
};
export default generatePreview;
+5 -2
View File
@@ -3,12 +3,14 @@ import { prisma } from "@/lib/api/db";
type Props = {
userId: number;
collectionId?: number;
collectionName?: string;
linkId?: number;
};
export default async function getPermission({
userId,
collectionId,
collectionName,
linkId,
}: Props) {
if (linkId) {
@@ -24,10 +26,11 @@ export default async function getPermission({
});
return check;
} else if (collectionId) {
} else if (collectionId || collectionName) {
const check = await prisma.collection.findFirst({
where: {
id: collectionId,
id: collectionId || undefined,
name: collectionName || undefined,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
},
include: { members: true },
+61
View File
@@ -0,0 +1,61 @@
import moveFile from "./storage/moveFile";
import removeFile from "./storage/removeFile";
const removeFiles = async (linkId: number, collectionId: number) => {
// PDF
await removeFile({
filePath: `archives/${collectionId}/${linkId}.pdf`,
});
// Images
await removeFile({
filePath: `archives/${collectionId}/${linkId}.png`,
});
await removeFile({
filePath: `archives/${collectionId}/${linkId}.jpeg`,
});
await removeFile({
filePath: `archives/${collectionId}/${linkId}.jpg`,
});
// Preview
await removeFile({
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
});
// Readability
await removeFile({
filePath: `archives/${collectionId}/${linkId}_readability.json`,
});
};
const moveFiles = async (linkId: number, from: number, to: number) => {
await moveFile(
`archives/${from}/${linkId}.pdf`,
`archives/${to}/${linkId}.pdf`
);
await moveFile(
`archives/${from}/${linkId}.png`,
`archives/${to}/${linkId}.png`
);
await moveFile(
`archives/${from}/${linkId}.jpeg`,
`archives/${to}/${linkId}.jpeg`
);
await moveFile(
`archives/${from}/${linkId}.jpg`,
`archives/${to}/${linkId}.jpg`
);
await moveFile(
`archives/preview/${from}/${linkId}.jpeg`,
`archives/preview/${to}/${linkId}.jpeg`
);
await moveFile(
`archives/${from}/${linkId}_readability.json`,
`archives/${to}/${linkId}_readability.json`
);
};
export { removeFiles, moveFiles };
+20 -2
View File
@@ -1,17 +1,35 @@
import fetch from "node-fetch";
import https from "https";
import { SocksProxyAgent } from "socks-proxy-agent";
export default async function validateUrlSize(url: string) {
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
try {
const httpsAgent = new https.Agent({
rejectUnauthorized:
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
});
const response = await fetch(url, {
let fetchOpts = {
method: "HEAD",
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 =
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
+15 -17
View File
@@ -17,15 +17,7 @@ export default async function verifySubscription(
const currentDate = new Date();
if (
subscription &&
currentDate > subscription.currentPeriodEnd &&
!subscription.active
) {
return null;
}
if (!subscription || currentDate > subscription.currentPeriodEnd) {
if (!subscription?.active || currentDate > subscription.currentPeriodEnd) {
const {
active,
stripeSubscriptionId,
@@ -59,15 +51,21 @@ export default async function verifySubscription(
},
})
.catch((err) => console.log(err));
}
} else if (!active) {
const subscription = await prisma.subscription.findFirst({
where: {
userId: user.id,
},
});
if (!active) {
if (user.username)
// await prisma.user.update({
// where: { id: user.id },
// data: { username: null },
// });
return null;
if (subscription)
await prisma.subscription.delete({
where: {
userId: user.id,
},
});
return null;
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ export default async function verifyUser({
}
if (STRIPE_SECRET_KEY) {
const subscribedUser = verifySubscription(user);
const subscribedUser = await verifySubscription(user);
if (!subscribedUser) {
res.status(401).json({
+45
View File
@@ -0,0 +1,45 @@
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
if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") {
return link.url || "";
} else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") {
if (!pdfAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
} else if (
account.linksRouteTo === LinksRouteTo.READABLE &&
link.type === "url"
) {
if (!readabilityAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
} else if (
account.linksRouteTo === LinksRouteTo.SCREENSHOT ||
link.type === "image"
) {
console.log(link);
if (!screenshotAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
}`;
} else {
return link.url || "";
}
};
+9 -3
View File
@@ -1,4 +1,8 @@
export function screenshotAvailable(link: any) {
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
export function screenshotAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
return (
link &&
link.image &&
@@ -7,13 +11,15 @@ export function screenshotAvailable(link: any) {
);
}
export function pdfAvailable(link: any) {
export function pdfAvailable(link: LinkIncludingShortenedCollectionAndTags) {
return (
link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable"
);
}
export function readabilityAvailable(link: any) {
export function readabilityAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
return (
link &&
link.readable &&
+37 -7
View File
@@ -1,5 +1,7 @@
import fetch from "node-fetch";
import https from "https";
import { SocksProxyAgent } from "socks-proxy-agent";
export default async function getTitle(url: string) {
try {
const httpsAgent = new https.Agent({
@@ -7,15 +9,43 @@ export default async function getTitle(url: string) {
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
});
const response = await fetch(url, {
// fetchOpts allows a proxy to be defined
let fetchOpts = {
agent: httpsAgent,
});
const text = await response.text();
};
// regular expression to find the <title> tag
let match = text.match(/<title.*>([^<]*)<\/title>/);
if (match) return match[1];
else return "";
if (process.env.PROXY) {
// parse proxy url
let proxy = new URL(process.env.PROXY);
// if authentication set, apply to proxy URL
if (process.env.PROXY_USERNAME) {
proxy.username = process.env.PROXY_USERNAME;
proxy.password = process.env.PROXY_PASSWORD || "";
}
// add socks5 proxy to fetchOpts
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
}
const 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) {
console.log(err);
}
+5
View File
@@ -1,10 +1,15 @@
/** @type {import('next').NextConfig} */
const { version } = require("./package.json");
const nextConfig = {
reactStrictMode: true,
images: {
domains: ["t2.gstatic.com"],
minimumCacheTTL: 10,
},
env: {
version,
},
};
module.exports = nextConfig;
+9 -4
View File
@@ -1,6 +1,6 @@
{
"name": "linkwarden",
"version": "2.4.8",
"version": "v2.5.4",
"main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -10,15 +10,16 @@
"seed": "node ./prisma/seed.js"
},
"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: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",
"lint": "next lint",
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
},
"dependencies": {
"@atlaskit/tree": "^8.8.7",
"@auth/prisma-adapter": "^1.0.1",
"@aws-sdk/client-s3": "^3.379.1",
"@headlessui/react": "^1.7.15",
@@ -44,12 +45,14 @@
"eslint-config-next": "13.4.9",
"formidable": "^3.5.1",
"framer-motion": "^10.16.4",
"himalaya": "^1.1.0",
"jimp": "^0.22.10",
"jsdom": "^22.1.0",
"lottie-web": "^5.12.2",
"micro": "^10.0.1",
"next": "13.4.12",
"next-auth": "^4.22.1",
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.3",
"playwright": "^1.35.1",
"react": "18.2.0",
@@ -58,6 +61,8 @@
"react-hot-toast": "^2.4.1",
"react-image-file-resizer": "^0.4.8",
"react-select": "^5.7.4",
"react-spinners": "^0.13.8",
"socks-proxy-agent": "^8.0.2",
"stripe": "^12.13.0",
"vaul": "^0.8.8",
"zustand": "^4.3.8"
@@ -74,7 +79,7 @@
"nodemon": "^3.0.2",
"postcss": "^8.4.26",
"prettier": "3.1.1",
"prisma": "^5.1.0",
"prisma": "^4.16.2",
"tailwindcss": "^3.3.3",
"ts-node": "^10.9.2",
"typescript": "4.9.4"
+27 -2
View File
@@ -5,7 +5,8 @@ import { SessionProvider } from "next-auth/react";
import type { AppProps } from "next/app";
import Head from "next/head";
import AuthRedirect from "@/layouts/AuthRedirect";
import { Toaster } from "react-hot-toast";
import toast from "react-hot-toast";
import { Toaster, ToastBar } from "react-hot-toast";
import { Session } from "next-auth";
import { isPWA } from "@/lib/client/utils";
@@ -61,7 +62,31 @@ export default function App({
className:
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
}}
/>
>
{(t) => (
<ToastBar toast={t}>
{({ icon, message }) => (
<div
className="flex flex-row"
data-testid="toast-message-container"
data-type={t.type}
>
{icon}
<span data-testid="toast-message">{message}</span>
{t.type !== "loading" && (
<button
className="btn btn-xs outline-none btn-circle btn-ghost"
data-testid="close-toast-button"
onClick={() => toast.dismiss(t.id)}
>
<i className="bi bi-x"></i>
</button>
)}
</div>
)}
</ToastBar>
)}
</Toaster>
<Component {...pageProps} />
</AuthRedirect>
</SessionProvider>
+174
View File
@@ -0,0 +1,174 @@
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
import NewUserModal from "@/components/ModalContent/NewUserModal";
import useUserStore from "@/store/admin/users";
import { User as U } from "@prisma/client";
import Link from "next/link";
import { Fragment, useEffect, useState } from "react";
interface User extends U {
subscriptions: {
active: boolean;
};
}
type UserModal = {
isOpen: boolean;
userId: number | null;
};
export default function Admin() {
const { users, setUsers } = useUserStore();
const [searchQuery, setSearchQuery] = useState("");
const [filteredUsers, setFilteredUsers] = useState<User[]>();
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
isOpen: false,
userId: null,
});
const [newUserModal, setNewUserModal] = useState(false);
useEffect(() => {
setUsers();
}, []);
return (
<div className="max-w-6xl mx-auto p-5">
<div className="flex sm:flex-row flex-col justify-between gap-2">
<div className="gap-2 inline-flex items-center">
<Link
href="/dashboard"
className="text-neutral btn btn-square btn-sm btn-ghost"
>
<i className="bi-chevron-left text-xl"></i>
</Link>
<p className="capitalize text-3xl font-thin inline">
User Administration
</p>
</div>
<div className="flex items-center relative justify-between gap-2">
<div>
<label
htmlFor="search-box"
className="inline-flex items-center w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary"
>
<i className="bi-search"></i>
</label>
<input
id="search-box"
type="text"
placeholder={"Search for Users"}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
if (users) {
setFilteredUsers(
users.filter((user) =>
JSON.stringify(user)
.toLowerCase()
.includes(e.target.value.toLowerCase())
)
);
}
}}
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none"
/>
</div>
<div
onClick={() => setNewUserModal(true)}
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"
>
<i className="bi-plus text-3xl absolute"></i>
</div>
</div>
</div>
<div className="divider my-3"></div>
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal)
) : searchQuery !== "" ? (
<p>No users found with the given search query.</p>
) : users && users.length > 0 ? (
UserListing(users, deleteUserModal, setDeleteUserModal)
) : (
<p>No users found.</p>
)}
{newUserModal ? (
<NewUserModal onClose={() => setNewUserModal(false)} />
) : null}
</div>
);
}
const UserListing = (
users: User[],
deleteUserModal: UserModal,
setDeleteUserModal: Function
) => {
return (
<div className="overflow-x-auto whitespace-nowrap w-full">
<table className="table w-full">
<thead>
<tr>
<th></th>
<th>Username</th>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<th>Email</th>
)}
{process.env.NEXT_PUBLIC_STRIPE === "true" && <th>Subscribed</th>}
<th>Created At</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr
key={index}
className="group hover:bg-neutral-content hover:bg-opacity-30 duration-100"
>
<td className="text-primary">{index + 1}</td>
<td>{user.username ? user.username : <b>N/A</b>}</td>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<td>{user.email}</td>
)}
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
<td>
{user.subscriptions?.active ? (
JSON.stringify(user.subscriptions?.active)
) : (
<b>N/A</b>
)}
</td>
)}
<td>{new Date(user.createdAt).toLocaleString()}</td>
<td className="relative">
<button
className="btn btn-sm btn-ghost duration-100 hidden group-hover:block absolute z-20 right-[0.35rem] top-[0.35rem]"
onClick={() =>
setDeleteUserModal({ isOpen: true, userId: user.id })
}
>
<i className="bi bi-trash"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
{deleteUserModal.isOpen && deleteUserModal.userId ? (
<DeleteUserModal
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
userId={deleteUserModal.userId}
/>
) : null}
</div>
);
};
+94 -78
View File
@@ -9,6 +9,8 @@ import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile";
import fs from "fs";
import verifyToken from "@/lib/api/verifyToken";
import generatePreview from "@/lib/api/generatePreview";
import createFolder from "@/lib/api/storage/createFolder";
export const config = {
api: {
@@ -73,83 +75,97 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.send(file);
}
} else if (req.method === "POST") {
const user = await verifyUser({ req, res });
if (!user) return;
const collectionPermissions = await getPermission({
userId: user.id,
linkId,
});
const memberHasAccess = collectionPermissions?.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
);
if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
// await uploadHandler(linkId, )
const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
const form = formidable({
maxFields: 1,
maxFiles: 1,
maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
});
form.parse(req, async (err, fields, files) => {
const allowedMIMETypes = [
"application/pdf",
"image/png",
"image/jpg",
"image/jpeg",
];
if (
err ||
!files.file ||
!files.file[0] ||
!allowedMIMETypes.includes(files.file[0].mimetype || "")
) {
// Handle parsing error
return res.status(500).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
});
} else {
const fileBuffer = fs.readFileSync(files.file[0].filepath);
const linkStillExists = await prisma.link.findUnique({
where: { id: linkId },
});
if (linkStillExists && files.file[0].mimetype?.includes("image")) {
const collectionId = collectionPermissions?.id as number;
createFolder({
filePath: `archives/preview/${collectionId}`,
});
generatePreview(fileBuffer, collectionId, linkId);
}
if (linkStillExists) {
await createFile({
filePath: `archives/${collectionPermissions?.id}/${
linkId + suffix
}`,
data: fileBuffer,
});
await prisma.link.update({
where: { id: linkId },
data: {
preview: files.file[0].mimetype?.includes("pdf")
? "unavailable"
: undefined,
image: files.file[0].mimetype?.includes("image")
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
: null,
pdf: files.file[0].mimetype?.includes("pdf")
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
: null,
lastPreserved: new Date().toISOString(),
},
});
}
fs.unlinkSync(files.file[0].filepath);
}
return res.status(200).json({
response: files,
});
});
}
// else if (req.method === "POST") {
// const user = await verifyUser({ req, res });
// if (!user) return;
// const collectionPermissions = await getPermission({
// userId: user.id,
// linkId,
// });
// const memberHasAccess = collectionPermissions?.members.some(
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate
// );
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
// return { response: "Collection is not accessible.", status: 401 };
// // await uploadHandler(linkId, )
// const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
// const form = formidable({
// maxFields: 1,
// maxFiles: 1,
// maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
// });
// form.parse(req, async (err, fields, files) => {
// const allowedMIMETypes = [
// "application/pdf",
// "image/png",
// "image/jpg",
// "image/jpeg",
// ];
// if (
// err ||
// !files.file ||
// !files.file[0] ||
// !allowedMIMETypes.includes(files.file[0].mimetype || "")
// ) {
// // Handle parsing error
// return res.status(500).json({
// response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
// });
// } else {
// const fileBuffer = fs.readFileSync(files.file[0].filepath);
// const linkStillExists = await prisma.link.findUnique({
// where: { id: linkId },
// });
// if (linkStillExists) {
// await createFile({
// filePath: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// data: fileBuffer,
// });
// await prisma.link.update({
// where: { id: linkId },
// data: {
// image: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// lastPreserved: new Date().toISOString(),
// },
// });
// }
// fs.unlinkSync(files.file[0].filepath);
// }
// return res.status(200).json({
// response: files,
// });
// });
// }
}
+43 -12
View File
@@ -98,19 +98,19 @@ if (
const user = await prisma.user.findFirst({
where: emailEnabled
? {
OR: [
{
username: username.toLowerCase(),
},
{
email: username?.toLowerCase(),
},
],
emailVerified: { not: null },
}
OR: [
{
username: username.toLowerCase(),
},
{
email: username?.toLowerCase(),
},
],
emailVerified: { not: null },
}
: {
username: username.toLowerCase(),
},
username: username.toLowerCase(),
},
});
let passwordMatches: boolean = false;
@@ -240,6 +240,37 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
};
}
// Authelia
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
providers.push(
{
id: "authelia",
name: "Authelia",
type: "oauth",
clientId: process.env.AUTHELIA_CLIENT_ID!,
clientSecret: process.env.AUTHELIA_CLIENT_SECRET!,
wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!,
authorization: { params: { scope: "openid email profile" } },
idToken: true,
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
username: profile.preferred_username,
}
},
}
);
const _linkAccount = adapter.linkAccount;
adapter.linkAccount = (account) => {
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
return _linkAccount ? _linkAccount(data) : undefined;
};
}
// Authentik
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
providers.push(
+2 -13
View File
@@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl";
import removeFile from "@/lib/api/storage/removeFile";
import { Collection, Link } from "@prisma/client";
import { removeFiles } from "@/lib/api/manageLinkFiles";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@@ -80,16 +80,5 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
},
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}.pdf`,
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}.png`,
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
});
await removeFile({
filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
});
await removeFiles(link.id, link.collection.id);
};
+17
View File
@@ -3,6 +3,8 @@ import getLinks from "@/lib/api/controllers/links/getLinks";
import postLink from "@/lib/api/controllers/links/postLink";
import { LinkRequestQuery } from "@/types/global";
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) {
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({
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,
});
}
}
+8 -1
View File
@@ -391,10 +391,17 @@ export function getLogins() {
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
});
}
// Authelia
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
buttonAuths.push({
method: "authelia",
name: process.env.AUTHELIA_CUSTOM_NAME ?? "Authelia",
});
}
return {
credentialsEnabled:
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
? "true"
: "false",
emailEnabled:
+11 -3
View File
@@ -16,9 +16,17 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
return null;
}
const userId = token?.id;
const user = await prisma.user.findUnique({
where: {
id: token?.id,
},
});
if (userId !== Number(req.query.id))
const isServerAdmin = process.env.ADMINISTRATOR === user?.username;
const userId = isServerAdmin ? Number(req.query.id) : token.id;
if (userId !== Number(req.query.id) && !isServerAdmin)
return res.status(401).json({ response: "Permission denied." });
if (req.method === "GET") {
@@ -53,7 +61,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
const updated = await updateUserById(userId, req.body);
return res.status(updated.status).json({ response: updated.response });
} else if (req.method === "DELETE") {
const updated = await deleteUserById(userId, req.body);
const updated = await deleteUserById(userId, req.body, isServerAdmin);
return res.status(updated.status).json({ response: updated.response });
}
}
+9
View File
@@ -1,9 +1,18 @@
import type { NextApiRequest, NextApiResponse } from "next";
import postUser from "@/lib/api/controllers/users/postUser";
import getUsers from "@/lib/api/controllers/users/getUsers";
import verifyUser from "@/lib/api/verifyUser";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
const response = await postUser(req, res);
return response;
} else if (req.method === "GET") {
const user = await verifyUser({ req, res });
if (!user || process.env.ADMINISTRATOR !== user.username)
return res.status(401).json({ response: "Unauthorized..." });
const response = await getUsers();
return res.status(response.status).json({ response: response.response });
}
}
+151 -24
View File
@@ -24,15 +24,18 @@ import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils";
import Link from "next/link";
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() {
const { settings } = useLocalSettingsStore();
const router = useRouter();
const { links } = useLinkStore();
const { links, selectedLinks, setSelectedLinks, deleteLinksById } =
useLinkStore();
const { collections } = useCollectionStore();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
@@ -81,6 +84,9 @@ export default function Index() {
};
fetchOwner();
// When the collection changes, reset the selected links
setSelectedLinks([]);
}, [activeCollection]);
const [editCollectionModal, setEditCollectionModal] = useState(false);
@@ -88,6 +94,13 @@ export default function Index() {
const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false);
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
if (editMode) return setEditMode(false);
}, [router]);
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
@@ -102,6 +115,35 @@ export default function Index() {
// @ts-ignore
const LinkComponent = linkView[viewMode];
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
response.ok &&
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}!`
);
};
return (
<MainLayout>
<div
@@ -135,7 +177,7 @@ export default function Index() {
<i className="bi-three-dots text-xl" title="More"></i>
</div>
<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>
<div
role="button"
@@ -148,7 +190,7 @@ export default function Index() {
Edit Collection Info
</div>
</li>
) : undefined}
)}
<li>
<div
role="button"
@@ -163,7 +205,7 @@ export default function Index() {
: "View Team"}
</div>
</li>
{permissions === true ? (
{permissions === true && (
<li>
<div
role="button"
@@ -176,7 +218,7 @@ export default function Index() {
Create Sub-Collection
</div>
</li>
) : undefined}
)}
<li>
<div
role="button"
@@ -196,7 +238,7 @@ export default function Index() {
</div>
)}
{activeCollection ? (
{activeCollection && (
<div className={`min-w-[15rem]`}>
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
<div
@@ -232,18 +274,17 @@ export default function Index() {
</div>
<p className="text-neutral text-sm font-semibold">
By {collectionOwner.name}
{activeCollection.members.length > 0
? ` and ${activeCollection.members.length} others`
: undefined}
{activeCollection.members.length > 0 &&
` and ${activeCollection.members.length} others`}
.
</p>
</div>
</div>
) : undefined}
)}
{activeCollection?.description ? (
{activeCollection?.description && (
<p>{activeCollection?.description}</p>
) : undefined}
)}
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
<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="flex justify-between items-end gap-5">
<div className="flex justify-between items-center gap-5">
<p>Showing {activeCollection?._count?.links} results</p>
<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} />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</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)) ? (
<LinkComponent
editMode={editMode}
links={links.filter(
(e) => e.collection.id === activeCollection?.id
)}
@@ -290,34 +403,48 @@ export default function Index() {
<NoLinksFound />
)}
</div>
{activeCollection ? (
{activeCollection && (
<>
{editCollectionModal ? (
{editCollectionModal && (
<EditCollectionModal
onClose={() => setEditCollectionModal(false)}
activeCollection={activeCollection}
/>
) : undefined}
{editCollectionSharingModal ? (
)}
{editCollectionSharingModal && (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={activeCollection}
/>
) : undefined}
{newCollectionModal ? (
)}
{newCollectionModal && (
<NewCollectionModal
onClose={() => setNewCollectionModal(false)}
parent={activeCollection}
/>
) : undefined}
{deleteCollectionModal ? (
)}
{deleteCollectionModal && (
<DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)}
activeCollection={activeCollection}
/>
) : undefined}
)}
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</>
) : undefined}
)}
</MainLayout>
);
}
+141 -4
View File
@@ -3,24 +3,73 @@ import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global";
import { Member, Sort, ViewMode } from "@/types/global";
import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import 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 { useRouter } from "next/router";
export default function Links() {
const { links } = useLinkStore();
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
useLinkStore();
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
);
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 });
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
response.ok &&
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}!`
);
};
const linkView = {
[ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView,
@@ -41,17 +90,105 @@ export default function Links() {
/>
<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} />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</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] ? (
<LinkComponent links={links} />
<LinkComponent editMode={editMode} links={links} />
) : (
<NoLinksFound text="You Haven't Created Any Links Yet" />
)}
</div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</MainLayout>
);
}
+139 -3
View File
@@ -2,16 +2,22 @@ import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import PageHeader from "@/components/PageHeader";
import { Sort, ViewMode } from "@/types/global";
import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import toast from "react-hot-toast";
// import GridView from "@/components/LinkViews/Layouts/GridView";
import { useRouter } from "next/router";
export default function PinnedLinks() {
const { links } = useLinkStore();
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
useLinkStore();
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
@@ -20,6 +26,48 @@ export default function PinnedLinks() {
useLinks({ sort: sortBy, pinnedOnly: true });
const router = useRouter();
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
const [editMode, setEditMode] = useState(false);
useEffect(() => {
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 = {
[ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView,
@@ -39,13 +87,87 @@ export default function PinnedLinks() {
description={"Pinned Links from your Collections"}
/>
<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} />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</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]) ? (
<LinkComponent links={links} />
<LinkComponent editMode={editMode} links={links} />
) : (
<div
style={{ flex: "1 1 auto" }}
@@ -62,6 +184,20 @@ export default function PinnedLinks() {
</div>
)}
</div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</MainLayout>
);
}
+1 -1
View File
@@ -60,8 +60,8 @@ export default function PublicCollections() {
name: true,
url: true,
description: true,
textContent: true,
tags: true,
textContent: false,
});
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
+21 -7
View File
@@ -5,12 +5,13 @@ import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import { Sort, ViewMode } from "@/types/global";
import { useRouter } from "next/router";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import PageHeader from "@/components/PageHeader";
import { GridLoader, PropagateLoader } from "react-spinners";
export default function Search() {
const { links } = useLinkStore();
@@ -21,8 +22,8 @@ export default function Search() {
name: true,
url: true,
description: true,
textContent: true,
tags: true,
textContent: false,
});
const [viewMode, setViewMode] = useState<string>(
@@ -30,7 +31,7 @@ export default function Search() {
);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({
const { isLoading } = useLinks({
sort: sortBy,
searchQueryString: decodeURIComponent(router.query.q as string),
searchByName: searchFilter.name,
@@ -40,6 +41,10 @@ export default function Search() {
searchByTags: searchFilter.tags,
});
useEffect(() => {
console.log("isLoading", isLoading);
}, [isLoading]);
const linkView = {
[ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView,
@@ -51,7 +56,7 @@ export default function Search() {
return (
<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">
<PageHeader icon={"bi-search"} title={"Search Results"} />
@@ -67,15 +72,24 @@ export default function Search() {
</div>
</div>
{links[0] ? (
<LinkComponent links={links} />
) : (
{!isLoading && !links[0] ? (
<p>
Nothing found.{" "}
<span className="font-bold text-xl" title="Shruggie">
¯\_()_/¯
</span>
</p>
) : links[0] ? (
<LinkComponent links={links} isLoading={isLoading} />
) : (
isLoading && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="m-auto py-10"
/>
)
)}
</div>
</MainLayout>
-106
View File
@@ -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>
);
}
-93
View File
@@ -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>
);
}
+237
View File
@@ -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
View File
@@ -1,6 +1,6 @@
import useLinkStore from "@/store/links";
import { useRouter } from "next/router";
import { FormEvent, useEffect, useState } from "react";
import { FormEvent, use, useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout";
import useTagStore from "@/store/tags";
import SortDropdown from "@/components/SortDropdown";
@@ -12,11 +12,15 @@ import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils";
import BulkDeleteLinksModal from "@/components/ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "@/components/ModalContent/BulkEditLinksModal";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
export default function Index() {
const router = useRouter();
const { links } = useLinkStore();
const { links, selectedLinks, deleteLinksById, setSelectedLinks } =
useLinkStore();
const { tags, updateTag, removeTag } = useTagStore();
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
@@ -26,11 +30,30 @@ export default function Index() {
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 });
useEffect(() => {
setActiveTag(tags.find((e) => e.id === Number(router.query.id)));
}, [router, tags]);
const tag = tags.find((e) => e.id === Number(router.query.id));
if (tags.length > 0 && !tag?.id) {
router.push("/dashboard");
return;
}
setActiveTag(tag);
}, [router, tags, Number(router.query.id), setActiveTag]);
useEffect(() => {
setNewTagName(activeTag?.name);
@@ -91,6 +114,35 @@ export default function Index() {
setRenameTag(false);
};
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(
`Deleting ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}...`
);
const response = await deleteLinksById(
selectedLinks.map((link) => link.id as number)
);
toast.dismiss(load);
response.ok &&
toast.success(
`Deleted ${selectedLinks.length} Link${
selectedLinks.length > 1 ? "s" : ""
}!`
);
};
const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card
);
@@ -195,16 +247,102 @@ export default function Index() {
</div>
<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} />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</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
editMode={editMode}
links={links.filter((e) =>
e.tags.some((e) => e.id === Number(router.query.id))
)}
/>
</div>
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</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
View File
@@ -38,9 +38,12 @@ model User {
tags Tag[]
pinnedLinks Link[]
collectionsJoined UsersAndCollections[]
collectionOrder Int[] @default([])
whitelistedUsers WhitelistedUser[]
accessTokens AccessToken[]
subscriptions Subscription?
linksRouteTo LinksRouteTo @default(ORIGINAL)
preventDuplicateLinks Boolean @default(false)
archiveAsScreenshot Boolean @default(true)
archiveAsPDF Boolean @default(true)
archiveAsWaybackMachine Boolean @default(false)
@@ -49,6 +52,13 @@ model User {
updatedAt DateTime @default(now()) @updatedAt
}
enum LinksRouteTo {
ORIGINAL
PDF
READABLE
SCREENSHOT
}
model WhitelistedUser {
id Int @id @default(autoincrement())
username String @default("")
@@ -83,8 +93,8 @@ model Collection {
links Link[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([name, ownerId])
@@index([ownerId])
}
model UsersAndCollections {
@@ -99,6 +109,7 @@ model UsersAndCollections {
updatedAt DateTime @default(now()) @updatedAt
@@id([userId, collectionId])
@@index([userId])
}
model Link {
@@ -117,6 +128,7 @@ model Link {
pdf String?
readable String?
lastPreserved DateTime?
importDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
}
@@ -131,6 +143,7 @@ model Tag {
updatedAt DateTime @default(now()) @updatedAt
@@unique([name, ownerId])
@@index([ownerId])
}
model Subscription {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 87 KiB

-47
View File
@@ -19,36 +19,16 @@ async function processBatch() {
url: { not: null },
OR: [
{
collection: {
owner: {
archiveAsScreenshot: true,
},
},
image: null,
},
{
collection: {
owner: {
archiveAsScreenshot: true,
},
},
image: "pending",
},
///////////////////////
{
collection: {
owner: {
archiveAsPDF: true,
},
},
pdf: null,
},
{
collection: {
owner: {
archiveAsPDF: true,
},
},
pdf: "pending",
},
///////////////////////
@@ -76,36 +56,16 @@ async function processBatch() {
url: { not: null },
OR: [
{
collection: {
owner: {
archiveAsScreenshot: true,
},
},
image: null,
},
{
collection: {
owner: {
archiveAsScreenshot: true,
},
},
image: "pending",
},
///////////////////////
{
collection: {
owner: {
archiveAsPDF: true,
},
},
pdf: null,
},
{
collection: {
owner: {
archiveAsPDF: true,
},
},
pdf: "pending",
},
///////////////////////
@@ -115,13 +75,6 @@ async function processBatch() {
{
readable: "pending",
},
///////////////////////
{
preview: null,
},
{
preview: "pending",
},
],
},
take: archiveTakeCount,
+66
View File
@@ -0,0 +1,66 @@
import { User as U } from "@prisma/client";
import { create } from "zustand";
interface User extends U {
subscriptions: {
active: boolean;
};
}
type ResponseObject = {
ok: boolean;
data: object | string;
};
type UserStore = {
users: User[];
setUsers: () => void;
addUser: (body: Partial<U>) => Promise<ResponseObject>;
removeUser: (userId: number) => Promise<ResponseObject>;
};
const useUserStore = create<UserStore>((set) => ({
users: [],
setUsers: async () => {
const response = await fetch("/api/v1/users");
const data = await response.json();
if (response.ok) set({ users: data.response });
else if (response.status === 401) window.location.href = "/dashboard";
},
addUser: async (body) => {
const response = await fetch("/api/v1/users", {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (response.ok)
set((state) => ({
users: [...state.users, data.response],
}));
return { ok: response.ok, data: data.response };
},
removeUser: async (userId) => {
const response = await fetch(`/api/v1/users/${userId}`, {
method: "DELETE",
});
const data = await response.json();
if (response.ok)
set((state) => ({
users: state.users.filter((user) => user.id !== userId),
}));
return { ok: response.ok, data: data.response };
},
}));
export default useUserStore;
+153 -1
View File
@@ -1,5 +1,8 @@
import { create } from "zustand";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import useTagStore from "./tags";
import useCollectionStore from "./collections";
@@ -10,23 +13,39 @@ type ResponseObject = {
type LinkStore = {
links: LinkIncludingShortenedCollectionAndTags[];
selectedLinks: LinkIncludingShortenedCollectionAndTags[];
setLinks: (
data: LinkIncludingShortenedCollectionAndTags[],
isInitialCall: boolean
) => void;
setSelectedLinks: (links: LinkIncludingShortenedCollectionAndTags[]) => void;
addLink: (
body: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>;
uploadFile: (
link: LinkIncludingShortenedCollectionAndTags,
file: File
) => Promise<ResponseObject>;
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
updateLink: (
link: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>;
updateLinks: (
links: LinkIncludingShortenedCollectionAndTags[],
removePreviousTags: boolean,
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
"tags" | "collectionId"
>
) => Promise<ResponseObject>;
removeLink: (linkId: number) => Promise<ResponseObject>;
deleteLinksById: (linkIds: number[]) => Promise<ResponseObject>;
resetLinks: () => void;
};
const useLinkStore = create<LinkStore>()((set) => ({
links: [],
selectedLinks: [],
setLinks: async (data, isInitialCall) => {
isInitialCall &&
set(() => ({
@@ -45,6 +64,7 @@ const useLinkStore = create<LinkStore>()((set) => ({
),
}));
},
setSelectedLinks: (links) => set({ selectedLinks: links }),
addLink: async (body) => {
const response = await fetch("/api/v1/links", {
body: JSON.stringify(body),
@@ -66,6 +86,82 @@ const useLinkStore = create<LinkStore>()((set) => ({
return { ok: response.ok, data: data.response };
},
uploadFile: async (link, file) => {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
} else {
return { ok: false, data: "Invalid file type." };
}
const response = await fetch("/api/v1/links", {
body: JSON.stringify({
...link,
type: linkType,
name: link.name ? link.name : file.name,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
const createdLink: LinkIncludingShortenedCollectionAndTags = data.response;
console.log(data);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
// get file extension
const extension = file.name.split(".").pop() || "";
set((state) => ({
links: [
{
...createdLink,
image:
linkType === "image"
? `archives/${createdLink.collectionId}/${
createdLink.id + extension
}`
: null,
pdf:
linkType === "pdf"
? `archives/${createdLink.collectionId}/${
createdLink.id + ".pdf"
}`
: null,
},
...state.links,
],
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
getLink: async (linkId, publicRoute) => {
const path = publicRoute
? `/api/v1/public/links/${linkId}`
@@ -122,6 +218,41 @@ const useLinkStore = create<LinkStore>()((set) => ({
return { ok: response.ok, data: data.response };
},
updateLinks: async (links, removePreviousTags, newData) => {
const response = await fetch("/api/v1/links", {
body: JSON.stringify({ links, removePreviousTags, newData }),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.map((e) =>
links.some((link) => link.id === e.id)
? {
...e,
collectionId: newData.collectionId ?? e.collectionId,
collection: {
...e.collection,
id: newData.collectionId ?? e.collection.id,
},
tags: removePreviousTags
? [...(newData.tags ?? [])]
: [...e.tags, ...(newData.tags ?? [])],
}
: e
),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
removeLink: async (linkId) => {
const response = await fetch(`/api/v1/links/${linkId}`, {
headers: {
@@ -142,6 +273,27 @@ const useLinkStore = create<LinkStore>()((set) => ({
return { ok: response.ok, data: data.response };
},
deleteLinksById: async (linkIds: number[]) => {
const response = await fetch("/api/v1/links", {
body: JSON.stringify({ linkIds }),
headers: {
"Content-Type": "application/json",
},
method: "DELETE",
});
const data = await response.json();
if (response.ok) {
set((state) => ({
links: state.links.filter((e) => !linkIds.includes(e.id as number)),
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
resetLinks: () => set({ links: [] }),
}));
-1
View File
@@ -1,5 +1,4 @@
import { create } from "zustand";
import { ViewMode } from "@/types/global";
type LocalSettings = {
theme?: string;

Some files were not shown because too many files have changed in this diff Show More