Compare commits

...

265 Commits

Author SHA1 Message Date
Daniel aefcd6d311 Merge pull request #295 from linkwarden/dev
Dev
2023-11-11 23:31:47 +03:30
daniel31x13 dd09fd9026 update version number 2023-11-11 15:00:48 -05:00
daniel31x13 b19d6694ec add route for pinned links + better dashboard UX 2023-11-11 14:57:46 -05:00
daniel31x13 49b1ea4875 more customizable link icons 2023-11-11 14:00:38 -05:00
Daniel ea82fb5825 Merge pull request #291 from linkwarden/visual-improvements
Visual improvements
2023-11-11 07:37:25 +03:30
daniel31x13 e3b32fd791 improved dashboard design + blurred icons based on personal preferences 2023-11-10 22:32:56 -05:00
daniel31x13 3dfbccaf23 better looking dashboard 2023-11-09 11:44:49 -05:00
Daniel 359507c014 Merge pull request #278 from linkwarden/dev
minor fix
2023-11-08 03:01:41 +03:30
daniel31x13 518b94b1f4 minor fix 2023-11-07 18:30:55 -05:00
Daniel f21033bd7a Merge pull request #277 from linkwarden/dev
Dev
2023-11-08 02:52:06 +03:30
daniel31x13 fbc1d4b113 hardcoded import size limit to 10mb to pass build error 2023-11-07 18:21:27 -05:00
daniel31x13 dba62d7028 updated README 2023-11-07 17:55:11 -05:00
Daniel 3aafc0960c Merge pull request #274 from linkwarden/dev
Linkwarden v2.0
2023-11-07 23:25:42 +03:30
daniel31x13 a2f03ff468 added hover effect to announcement bar components 2023-11-07 14:54:37 -05:00
daniel31x13 c300da172b added url to announcment bar 2023-11-07 14:51:22 -05:00
daniel31x13 2f4af7f3d9 added announcement bar 2023-11-07 13:06:42 -05:00
daniel31x13 cb5b1751c0 bug fix 2023-11-07 08:03:35 -05:00
daniel31x13 6f5245cbc4 minor change 2023-11-06 10:54:39 -05:00
daniel31x13 9bee9b8ae4 bug fix 2023-11-06 10:06:14 -05:00
daniel31x13 7bdef522c1 bug fixed 2023-11-06 10:01:39 -05:00
daniel31x13 c8edc3844b code refactoring + many security/bug fixes 2023-11-06 08:25:57 -05:00
daniel31x13 b5a28f68ad remove tag functionality 2023-11-03 00:09:50 -04:00
daniel31x13 ae1889e757 support for bearer tokens 2023-11-02 14:59:31 -04:00
daniel31x13 b458fad567 WIP changes 2023-11-02 01:52:49 -04:00
daniel31x13 b1b0d98eb2 search by text content functionality 2023-11-01 06:01:26 -04:00
daniel31x13 b1c6a3faf1 readable format styling 2023-10-31 18:02:41 -04:00
daniel31x13 56a281ae3d rearchive protection 2023-10-31 15:44:58 -04:00
daniel31x13 ccafc997fc minor change 2023-10-31 05:41:19 -04:00
daniel31x13 417c16d08b minor UI change 2023-10-31 05:39:05 -04:00
daniel31x13 dbeefecec6 better design 2023-10-31 05:35:45 -04:00
daniel31x13 35665ce292 minor fix 2023-10-30 15:21:16 -04:00
daniel31x13 fb61812356 fully added reader view support 2023-10-30 15:20:15 -04:00
daniel31x13 ed91c4267b changed readable format to json 2023-10-30 00:50:43 -04:00
daniel31x13 c9c62b615b finished implementing readable mode api side 2023-10-30 00:30:45 -04:00
daniel31x13 de20fb7bc1 minor change 2023-10-29 12:56:38 -04:00
daniel31x13 16024f40be added new api route + fixed dropdown 2023-10-29 00:57:24 -04:00
daniel31x13 2856e23a4a fixed the dropdown 2023-10-28 12:50:11 -04:00
daniel31x13 db47a2a142 [WIP] dropdown bug 2023-10-28 07:20:35 -04:00
daniel31x13 ac795cdbdc added rearchive functionallity + dropdown fix [WIP] 2023-10-28 05:57:53 -04:00
daniel31x13 9b6038201c bug fixed 2023-10-28 01:46:51 -04:00
daniel31x13 9486d699c9 bug fixed 2023-10-28 01:42:31 -04:00
daniel31x13 cdcfabec0b refactored how avatars are being handled 2023-10-28 00:45:14 -04:00
Daniel f9eedadb9f Merge pull request #265 from linkwarden/dependabot/npm_and_yarn/crypto-js-4.2.0
Bump crypto-js from 4.1.1 to 4.2.0
2023-10-27 23:04:37 -04:00
daniel31x13 c08e7d4580 updated prisma schema 2023-10-27 16:06:42 -04:00
daniel31x13 ea86737835 bugs fixed 2023-10-26 18:49:46 -04:00
dependabot[bot] 788fc56caf Bump crypto-js from 4.1.1 to 4.2.0
Bumps [crypto-js](https://github.com/brix/crypto-js) from 4.1.1 to 4.2.0.
- [Commits](https://github.com/brix/crypto-js/compare/4.1.1...4.2.0)

---
updated-dependencies:
- dependency-name: crypto-js
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-25 23:23:38 +00:00
daniel31x13 966136dab6 created migration script [WIP] 2023-10-25 15:42:36 -04:00
Daniel 4454e615b6 Merge pull request #262 from linkwarden/dev
Dev
2023-10-24 17:23:14 -04:00
Daniel 91748ac5d2 Update issue templates 2023-10-24 17:22:45 -04:00
daniel31x13 2be2a83c62 minor fix 2023-10-24 17:11:25 -04:00
daniel31x13 c38c5b2cc5 improved user experience 2023-10-24 17:03:33 -04:00
daniel31x13 cb8c2d5f10 finished adding profile deletion functionality + bug fix 2023-10-24 15:57:37 -04:00
Daniel 8fdc503f55 Merge pull request #258 from linkwarden/dev
Dev
2023-10-23 15:25:19 -04:00
daniel31x13 97d8c35d2a added delete user endpoint 2023-10-23 15:24:22 -04:00
Daniel 4ffbc4e2f6 Update issue templates 2023-10-23 22:04:44 +03:30
daniel31x13 4252b79586 added recent links to dashboard 2023-10-23 10:45:48 -04:00
Daniel ee4554ae95 Merge pull request #253 from linkwarden/dev
fixed UI bug
2023-10-23 01:56:24 -04:00
daniel31x13 697b139493 fixed UI bug 2023-10-23 01:55:44 -04:00
Daniel a4ea023c51 Merge pull request #252 from linkwarden/dev
Dev
2023-10-23 01:46:23 -04:00
daniel31x13 bcae97a296 bug fixed 2023-10-23 01:45:31 -04:00
Daniel 565ee92d20 Merge pull request #236 from YeeJiaWei/login-with-enter
html form for login & register using enter key
2023-10-23 01:21:05 -04:00
daniel31x13 ec4bfa6ba9 merged "AuthSubmitButton" with the "SubmitButton" + updated the other pages that needed this change 2023-10-23 01:20:08 -04:00
Daniel 68f0f03d0d Merge pull request #251 from linkwarden/main
update issue templates
2023-10-23 00:31:39 -04:00
Daniel 86cfdd508a Merge pull request #250 from linkwarden/dev
refactored/cleaned up API + added support for renaming tags
2023-10-23 00:30:30 -04:00
daniel31x13 ed24685aaf refactored/cleaned up API + added support for renaming tags 2023-10-23 00:28:39 -04:00
Daniel 84d4153b5c Update issue templates 2023-10-23 00:26:26 -04:00
Daniel ad525b8b00 Merge pull request #243 from linkwarden/dev
increase timeout to pass github actions arm64 build
2023-10-20 23:59:26 -04:00
daniel31x13 24cced9dba increase timeout to pass github actions arm64 build 2023-10-20 23:58:38 -04:00
Daniel 3626ea613c Merge pull request #242 from linkwarden/dev
minor change to DockerFile
2023-10-20 23:07:25 -04:00
daniel31x13 aaebdc5da7 minor change to DockerFile 2023-10-20 23:06:09 -04:00
Daniel 748f181bc2 Merge pull request #241 from linkwarden/dev
downgrade node to pass build
2023-10-20 22:50:10 -04:00
daniel31x13 d7705b585e downgrade node to pass build 2023-10-20 22:49:43 -04:00
Daniel a996dda349 Merge pull request #240 from linkwarden/dev
minor cleanup + updated twitter handle
2023-10-20 15:35:17 -04:00
daniel31x13 db389c05a8 minor cleanup + updated twitter handle 2023-10-20 15:34:47 -04:00
Daniel c35532a467 Merge pull request #237 from linkwarden/dev
Minor rendering fix
2023-10-19 01:56:15 -04:00
Yee Jia Wei b3295e136d change login and register to form 2023-10-19 13:46:40 +08:00
daniel31x13 d445ea3194 settings page layout fix 2023-10-19 01:37:00 -04:00
daniel31x13 40bb9e5e52 update README.md 2023-10-19 00:29:27 -04:00
Daniel b987dbe79b Merge pull request #235 from linkwarden/dev
Dev
2023-10-19 00:21:32 -04:00
daniel31x13 146b8576f4 cleared up old code 2023-10-19 00:20:28 -04:00
daniel31x13 42e16cbf04 minor change 2023-10-19 00:17:35 -04:00
daniel31x13 93d1b00bbe optimization 2023-10-19 00:15:42 -04:00
daniel31x13 4482c52fa9 fixed build error 2023-10-19 00:09:28 -04:00
daniel31x13 ca3ce7e3de adjustible archive formats + finalized settings page 2023-10-19 00:00:23 -04:00
daniel31x13 19467f243f redesigned settings page [still WIP] 2023-10-18 17:50:55 -04:00
daniel31x13 f5eaee8dc0 bug fixed + redesigned profile settings page 2023-10-17 16:02:07 -04:00
Daniel 86b2cd45e0 Merge pull request #230 from linkwarden/dev
added export functionality
2023-10-16 18:28:28 -04:00
daniel31x13 6259405045 added export functionality 2023-10-16 18:27:04 -04:00
Daniel 8906639147 Merge pull request #229 from linkwarden/dev
Dev
2023-10-16 13:12:37 -04:00
daniel31x13 f98500ec4e fixed filter by tags + refactored search + bug fixed + settings page [WIP] 2023-10-16 13:10:52 -04:00
daniel31x13 36a1ed209e small design improvements 2023-10-13 02:14:18 -04:00
daniel31x13 754c15d2bb added support for wayback machine 2023-10-13 02:03:38 -04:00
Daniel 943933534d Merge pull request #223 from linkwarden/dev
Update dashboard.tsx
2023-10-12 15:00:47 -04:00
daniel31x13 6a38ad961a Update dashboard.tsx 2023-10-12 14:59:24 -04:00
Daniel a1f8176a0b Merge pull request #220 from linkwarden/dev
Dev
2023-10-11 18:50:54 -04:00
daniel31x13 2590b7d7cf updated README 2023-10-11 18:50:19 -04:00
daniel31x13 49f826f06b minor fix 2023-10-11 18:49:08 -04:00
Daniel feef1c298a Merge pull request #219 from linkwarden/dev
Dev
2023-10-11 13:20:11 -04:00
daniel31x13 ac47ca5c54 small change 2023-10-11 13:19:47 -04:00
daniel31x13 89436d3500 small change 2023-10-11 13:19:13 -04:00
daniel31x13 3d6cb45382 added page title to public pages 2023-10-11 12:18:45 -04:00
Daniel 1e70b7a596 Merge pull request #218 from linkwarden/dev
updated README
2023-10-10 05:28:53 -04:00
daniel31x13 727b05bdbd updated README 2023-10-10 05:28:26 -04:00
Daniel 7a589cbaad Merge pull request #213 from linkwarden/dev
Dev
2023-10-09 13:07:21 -04:00
daniel31x13 3c2029e120 updated README 2023-10-09 13:06:24 -04:00
Daniel fac826f040 Dev (#212) 2023-10-09 13:04:47 -04:00
daniel31x13 d443755e19 updated README 2023-10-09 13:03:28 -04:00
daniel31x13 156e7ffebf updated README 2023-10-09 13:02:47 -04:00
daniel31x13 ad0f765896 updated README 2023-10-09 13:01:51 -04:00
Daniel 64e2efe8d0 Merge pull request #211 from linkwarden/dev
Dev
2023-10-09 13:00:15 -04:00
daniel31x13 81354f95ff updated README 2023-10-09 12:59:27 -04:00
daniel31x13 d2eef8955e updated README 2023-10-09 12:44:31 -04:00
daniel31x13 efed225ad1 updated readme 2023-10-09 12:41:06 -04:00
Daniel 85db0d0e77 Merge pull request #210 from linkwarden/dev
UI improvements
2023-10-09 08:37:54 -04:00
daniel31x13 48eb253cac UI improvements 2023-10-09 08:35:33 -04:00
Daniel 64f9f9e2c5 Merge pull request #204 from linkwarden/dev
minor change
2023-10-06 10:23:52 +03:30
daniel31x13 417252d65e minor change 2023-10-06 10:23:14 +03:30
Daniel 5a16f09599 Merge pull request #203 from linkwarden/dev
more concise confirmation info
2023-10-06 10:21:32 +03:30
daniel31x13 a51ce8bc2d more concise confirmation info 2023-10-06 10:21:04 +03:30
Daniel eb643ef19e Merge pull request #202 from linkwarden/dev
minor ui change
2023-10-06 09:57:07 +03:30
daniel31x13 42f770341e minor ui change 2023-10-06 09:56:30 +03:30
Daniel 2aa2a63708 Merge pull request #201 from linkwarden/dev
improved subscription page
2023-10-06 09:10:07 +03:30
daniel31x13 83938fdd0a improved subscription page 2023-10-06 09:09:16 +03:30
Daniel 09b91afc95 Merge pull request #200 from linkwarden/dev
updated README
2023-10-05 22:35:12 +03:30
daniel31x13 d2ee9c4fce updated README 2023-10-05 22:34:22 +03:30
Daniel 0132135f64 Merge pull request #199 from linkwarden/dev
updated README file
2023-10-05 22:25:44 +03:30
daniel31x13 f8b1db08db updated README file 2023-10-05 22:24:47 +03:30
Daniel d89b776f90 Merge pull request #198 from linkwarden/dev
Revert "updated nextjs"
2023-10-05 20:29:39 +03:30
daniel31x13 610c1c80ed Revert "updated nextjs" 2023-10-05 20:27:44 +03:30
Daniel de77c4997d Merge pull request #197 from linkwarden/dev
hot fix
2023-10-05 19:50:35 +03:30
daniel31x13 35ad8320b9 hot fix 2023-10-05 19:49:31 +03:30
Daniel efbf132dbb Merge pull request #194 from linkwarden/dev
Dev
2023-10-05 19:32:14 +03:30
daniel31x13 f1017533d7 updated nextjs 2023-10-05 19:31:32 +03:30
daniel31x13 3282d5a615 bug fixed + small visual improvements + improved user experience 2023-10-05 19:12:35 +03:30
Daniel 8e18966952 Merge pull request #190 from linkwarden/dev
Dev
2023-10-03 14:36:50 +03:30
daniel31x13 f15e298cc3 import bookmarks from other platforms + many other improvements 2023-10-03 14:34:13 +03:30
daniel31x13 8fc8874063 renamed the "data" route to "migration" 2023-10-01 20:03:40 +03:30
Daniel ea7f08aba2 Merge pull request #184 from linkwarden/dev
added the ability to disable registration
2023-09-28 19:08:43 +03:30
daniel31x13 fdcae013c6 added the ability to disable registration 2023-09-28 19:07:25 +03:30
Daniel e02251c09c Merge pull request #183 from linkwarden/dev
updated the image
2023-09-28 11:29:37 +03:30
daniel31x13 7585d52750 updated the image 2023-09-28 11:28:46 +03:30
Daniel f573b0a8f3 Merge pull request #182 from linkwarden/dev
Dev
2023-09-28 10:27:08 +03:30
daniel31x13 62cfcfef14 changed the actions file 2023-09-28 10:26:43 +03:30
Daniel 138723c721 Merge pull request #181 from peschmae/feature/github-action-arm64
chore(builds) Enable arm64 builds
2023-09-28 10:20:27 +03:30
Mathias Petermann de8a90a80e chore(builds) Enable arm64 builds 2023-09-28 08:06:37 +02:00
Daniel da2a14b4f2 Merge pull request #180 from linkwarden/dev
Dev
2023-09-28 00:24:39 +03:30
daniel31x13 d54760f12b minor change to README file 2023-09-28 00:24:22 +03:30
Daniel 83b15c92e5 Merge pull request #179 from linkwarden/main
main
2023-09-27 08:58:57 +03:30
daniel31x13 420d9efb7e updated docker compose file to use the image from github packages. 2023-09-27 08:56:52 +03:30
Daniel 66c1c582f5 Merge pull request #178 from linkwarden/dev
Dev
2023-09-27 05:36:53 +03:30
Daniel d9c7b934aa Merge pull request #166 from peschmae/feature/github-workflow
Add workflow to build container images on tags
2023-09-27 05:36:23 +03:30
Daniel 38dd77cf42 Merge pull request #177 from linkwarden/dev
renamed image
2023-09-27 01:25:01 +03:30
daniel31x13 79a7605ed9 renamed image 2023-09-27 01:24:25 +03:30
Daniel 1473a66242 Merge pull request #176 from linkwarden/dev
Dev
2023-09-26 13:25:48 +03:30
Daniel 8ad84f571c minor change to README.md 2023-09-26 12:38:54 +03:30
Daniel 701d75b97e minor change to README.md 2023-09-26 11:02:08 +03:30
Daniel 185d67db0c updated README.md 2023-09-26 10:59:29 +03:30
Daniel 8109e8a6a3 fixed typo 2023-09-26 10:28:59 +03:30
Daniel 50a9d732a3 updated README.md 2023-09-26 10:27:35 +03:30
Daniel fc066cba0d updated logo and README.md 2023-09-26 10:09:23 +03:30
Daniel 1dbae67443 Merge pull request #170 from linkwarden/dev
Dev
2023-09-25 01:14:33 +03:30
Daniel c7d52889cc minor change 2023-09-25 01:13:36 +03:30
Daniel 2025d7649f Merge pull request #168 from peschmae/feature/slim-docker-image
Use official nodejs base image, slim down container image
2023-09-20 07:48:22 -04:00
Mathias Petermann 3eb273c25e Use official nodejs base image, slim down image
- Playwright 1.35 supports debian 11 (bullseye), for debian 12 (bookworm) support, playwright needs to be updated
- Ensure all apt & yarn caches are cleaned in the same step they are used (slims down image)
- Image uses a lot less steps now, and slimmed down form ~4GB to 2.5GB
2023-09-20 08:19:52 +02:00
Mathias Petermann 7ef6d97462 Add workflow to build container images on tags 2023-09-19 21:24:06 +02:00
Daniel f242d8289a Merge pull request #160 from linkwarden/dev
Dev
2023-09-13 00:15:36 -04:00
Daniel 5583fd82f3 added yearly plan 2023-09-11 00:20:31 -04:00
Daniel 8fd108c74e minor fix 2023-09-06 23:13:58 -04:00
Daniel c91ed73d64 Update README.md 2023-09-06 23:07:15 -04:00
Daniel 85788cb9ff minor rendering fix 2023-09-05 16:04:34 -04:00
Daniel 4365547867 changes and improvements 2023-09-05 14:12:27 -04:00
Daniel 6a603d7d56 styling improvements 2023-09-01 18:04:11 -04:00
Daniel 0b8d8c0645 minor fix 2023-08-31 23:39:39 -04:00
Daniel fdfb3a927e small coloring changes 2023-08-31 00:17:27 -04:00
Daniel 11539ade6c small fix 2023-08-31 00:08:45 -04:00
Daniel fab9a06d95 minor improvements 2023-08-31 00:00:57 -04:00
Daniel 5015f79b81 improved ux + coloring fix + improved dashboard ui 2023-08-28 14:03:06 -04:00
Daniel e47aef8123 improved coloring 2023-08-22 18:34:46 -04:00
Daniel acc974ecfe bug fix + ux improvements 2023-08-22 00:43:34 -04:00
Daniel 09ea45eec0 better stripe logic 2023-08-21 16:11:13 -04:00
Daniel 8dfd1598f3 refactored api routes 2023-08-20 12:00:42 -04:00
Daniel b0e92c6253 Merge pull request #145 from linkwarden/feat/dark-mode
Feat/dark mode
2023-08-17 16:14:20 -04:00
Daniel bf8a0df4c2 better input coloring in darkmode 2023-08-17 16:05:44 -04:00
Daniel 122b331efa final touch on darkmode 2023-08-15 21:29:38 -04:00
Daniel da92d46f7b Merge pull request #131 from g1sbi/feat/dark-mode
Feat: Dark mode
2023-08-14 23:27:07 -04:00
Daniel 1701ba07d4 much better coloring 2023-08-14 23:25:25 -04:00
Daniel 7da89a35e2 many coloring changes and improvements 2023-08-11 01:11:02 -04:00
Daniel 1eb1467a02 confirmed dark color 2023-08-11 00:44:44 -04:00
Daniel 5d016068c7 minor change to theme toggle 2023-08-11 00:25:02 -04:00
Daniel 83349ea065 Merge branch 'feat/dark-mode' into feat/dark-mode 2023-08-10 20:50:30 -04:00
Daniel 9ef1d3db23 Merge pull request #137 from linkwarden/dev
Dev
2023-08-10 13:37:29 -04:00
Daniel 543dfd156c minor fix in the docker compose file 2023-08-10 13:36:23 -04:00
Daniel d008c441b7 Feat/import export (#136)
* added import/export functionality
2023-08-10 12:16:44 -04:00
Gisbi cff3b97ab7 finished working on dark mode 2023-08-07 11:44:09 +02:00
Gisbi 93ebc09faf working on dark mode 2023-08-06 16:13:45 +02:00
Daniel 3bff771c46 Merge pull request #124 from linkwarden/dev
bug fix
2023-08-06 00:59:02 -04:00
Daniel 159075b38b bug fix 2023-08-06 00:58:18 -04:00
Daniel 8a767108d3 Merge branch 'main' of https://github.com/Daniel31x13/linkwarden 2023-08-05 22:08:03 -04:00
Daniel aeb88def6d Merge branch 'dev' of https://github.com/Daniel31x13/linkwarden 2023-08-05 22:07:55 -04:00
Daniel 89427f16f5 Merge pull request #119 from linkwarden/dev
minor fix
2023-08-04 20:32:42 -04:00
Daniel 02b7a90160 minor fix 2023-08-04 20:32:13 -04:00
Daniel 5406221f89 Merge pull request #118 from linkwarden/dev
Dev
2023-08-04 19:46:44 -04:00
Daniel a56b8e24da updated migration file 2023-08-04 19:26:57 -04:00
Daniel 2177f12b9b Merge pull request #115 from crkos/dev
add sqlite compatibility + fix whitespace bug collections
2023-08-04 18:37:39 -04:00
Jordan Higuera Higuera 9405445332 identation fix 2023-08-04 15:10:31 -07:00
Jordan Higuera Higuera 91f9fcb500 changed default provider 2023-08-04 14:41:53 -07:00
Jordan Higuera Higuera 8747331c43 remove unused import 2023-08-04 10:13:23 -07:00
Jordan Higuera Higuera 895ef8e60f add mode insensitive in case we are using postgresql + rename table 2023-08-04 10:08:04 -07:00
Jordan Higuera Higuera 22093c0c29 add sqlite compatibility + fix whitespace bug collections 2023-08-03 21:33:51 -07:00
Daniel c9cd2986dd Merge pull request #114 from linkwarden/dev
fixed typo
2023-08-03 19:48:50 -04:00
Daniel 264ea03e63 fixed typo 2023-08-03 19:48:23 -04:00
Daniel 5132473322 Merge pull request #113 from linkwarden/dev
Dev
2023-08-03 19:46:25 -04:00
Daniel c6e7a329ab Merge branch 'dev' of https://github.com/Daniel31x13/linkwarden into dev 2023-08-03 19:44:53 -04:00
Daniel 21525b2920 updated readme 2023-08-03 19:44:49 -04:00
Daniel 0b0389d169 Merge pull request #112 from linkwarden/dev
Dev
2023-08-03 19:01:42 -04:00
Daniel 32e7bfe09c Update confirmation.tsx 2023-08-03 19:01:20 -04:00
Daniel 4be3125f9a easier docker setup 2023-08-03 18:31:40 -04:00
Daniel a8009734a9 added docker bind mounts + bug fix 2023-08-03 17:54:04 -04:00
Daniel 01108a4bb4 Merge pull request #111 from linkwarden/main
Main
2023-08-03 15:18:34 -04:00
Daniel 5ba12dabdc Added Docker Support
Dev
2023-08-03 14:31:58 -04:00
Daniel 5ba3fd7b6c minor change 2023-08-03 14:25:46 -04:00
Daniel 49423ddb51 Merge pull request #106 from bijeebuss/main
bare bones docker setup
2023-08-03 14:24:24 -04:00
Daniel 44f17ba0ff Merge pull request #109 from linkwarden/dev
major bug fixed
2023-08-03 14:05:38 -04:00
michael welnick 9b56257542 Merge branch 'dev' 2023-08-03 11:05:12 -07:00
Daniel 1bb1d8140d major bug fixed 2023-08-03 14:03:06 -04:00
michael welnick fc4d27d431 use data volume 2023-08-03 10:38:44 -07:00
michael welnick d3300d7cc9 dockerignore and do primsa generate 2023-08-03 09:21:08 -07:00
michael welnick 922d145570 bare bones docker setup 2023-08-02 23:52:37 +00:00
Gisbi 64c417c1be working on dark mode 2023-08-02 19:53:55 +02:00
Daniel 501e9e59e0 Merge pull request #104 from linkwarden/dev
Updated the dependencies
2023-08-01 22:23:57 -04:00
Daniel ffb1098a15 Merge pull request #71 from linkwarden/dependabot/npm_and_yarn/dev/next-13.4.12
Bump next from 13.1.6 to 13.4.12
2023-08-01 22:16:14 -04:00
Daniel 808ed9b15b Merge pull request #87 from linkwarden/dependabot/npm_and_yarn/dev/react-select-5.7.4
Bump react-select from 5.7.3 to 5.7.4
2023-08-01 22:13:35 -04:00
dependabot[bot] 232c95937d Bump next from 13.1.6 to 13.4.12
Bumps [next](https://github.com/vercel/next.js) from 13.1.6 to 13.4.12.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v13.1.6...v13.4.12)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-08-02 02:08:13 +00:00
Daniel 857117dc0f Merge pull request #88 from linkwarden/dependabot/npm_and_yarn/dev/eslint-8.46.0
Bump eslint from 8.44.0 to 8.46.0
2023-08-01 22:08:13 -04:00
dependabot[bot] 2f0c2f2999 Bump eslint from 8.44.0 to 8.46.0
Bumps [eslint](https://github.com/eslint/eslint) from 8.44.0 to 8.46.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.44.0...v8.46.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-08-02 02:05:19 +00:00
Daniel 1fc0804cbf error handling missing avatar 2023-08-01 22:03:49 -04:00
Daniel 4c28e211ec Merge pull request #103 from linkwarden/dependabot/npm_and_yarn/dev/prisma-5.1.0
Bump prisma from 4.16.2 to 5.1.0
2023-08-01 19:44:52 -04:00
dependabot[bot] c29219c492 Bump prisma from 4.16.2 to 5.1.0
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 4.16.2 to 5.1.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/5.1.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-08-01 23:36:44 +00:00
Daniel e22362808e Merge pull request #89 from linkwarden/dependabot/npm_and_yarn/dev/aws-sdk/client-s3-3.379.1
Bump @aws-sdk/client-s3 from 3.363.0 to 3.379.1
2023-08-01 19:32:44 -04:00
Daniel 94a6212caa Merge pull request #102 from linkwarden/dev
eslint fix
2023-08-01 16:32:11 -04:00
Daniel 46c897545f eslint fix 2023-08-01 16:31:30 -04:00
Daniel 86c839e7fe Merge pull request #101 from linkwarden/dev
better layout + improvements
2023-08-01 16:21:55 -04:00
Daniel 8c49b33154 better layout + improvements 2023-08-01 16:20:05 -04:00
Daniel cacbd7b400 Merge pull request #96 from linkwarden/dev
Update README.md
2023-08-01 00:51:14 -04:00
Daniel dc86c5487a Update README.md 2023-08-01 00:50:28 -04:00
Daniel a3dd3660b4 Merge pull request #95 from linkwarden/dev
Dev
2023-08-01 00:35:17 -04:00
Daniel d81ecf039b Delete CONTRIBUTING.md 2023-08-01 00:33:10 -04:00
Daniel 00c4a01f53 Merge pull request #93 from linkwarden/main
Main
2023-07-31 12:10:19 -04:00
Daniel 7d388e50f6 Merge pull request #92 from linkwarden/daniel31x13-patch-1
Update README.md
2023-07-31 12:09:34 -04:00
Daniel cbbc30f8b9 Update README.md 2023-07-31 12:09:24 -04:00
Daniel 80c11abb7f Merge pull request #91 from linkwarden/dev
Update README.md
2023-07-31 10:44:09 -04:00
Daniel e8709d8f23 Update README.md 2023-07-31 10:43:55 -04:00
Daniel 9ae3322224 Merge pull request #90 from linkwarden/dev
Dev
2023-07-31 09:38:23 -04:00
Daniel 880577f524 updated README screenshots 2023-07-31 08:44:13 -04:00
Daniel 94e4b58341 improved README.md 2023-07-31 08:08:52 -04:00
Daniel 1911571219 Update README.md 2023-07-31 06:52:27 -04:00
Daniel c46de09bb5 small change in README.md file 2023-07-31 02:22:47 -04:00
Daniel a3c2c78bc5 minor change in README.md file 2023-07-31 02:19:49 -04:00
Daniel d87715e759 minor improvement 2023-07-31 02:17:48 -04:00
Daniel deeaa6884a fixed minor typo 2023-07-31 02:12:25 -04:00
Daniel 0940396b2e fixed typos + changes to the readme file 2023-07-31 02:07:48 -04:00
Daniel 923750a6f7 minor fix 2023-07-31 01:56:33 -04:00
Daniel 4de6db7720 updated README.md 2023-07-31 01:54:53 -04:00
dependabot[bot] 657147a9bd Bump @aws-sdk/client-s3 from 3.363.0 to 3.379.1
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.363.0 to 3.379.1.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.379.1/clients/client-s3)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-31 04:34:20 +00:00
dependabot[bot] 14e9e9ef1c Bump react-select from 5.7.3 to 5.7.4
Bumps [react-select](https://github.com/JedWatson/react-select) from 5.7.3 to 5.7.4.
- [Release notes](https://github.com/JedWatson/react-select/releases)
- [Changelog](https://github.com/JedWatson/react-select/blob/master/docs/CHANGELOG.md)
- [Commits](https://github.com/JedWatson/react-select/compare/react-select@5.7.3...react-select@5.7.4)

---
updated-dependencies:
- dependency-name: react-select
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-31 04:28:27 +00:00
197 changed files with 9001 additions and 4446 deletions
+22
View File
@@ -0,0 +1,22 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
"remoteUser": "root"
}
+7
View File
@@ -0,0 +1,7 @@
node_modules
pgdata
.env
.devcontainer
docker-compose.yml
Dockerfile
README.md
+5 -8
View File
@@ -6,6 +6,9 @@ NEXTAUTH_URL=http://localhost:3000
PAGINATION_TAKE_COUNT=
STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION=
RE_ARCHIVE_LIMIT=
# AWS S3 Settings
SPACES_KEY=
@@ -18,11 +21,5 @@ NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM=
EMAIL_SERVER=
# Stripe settings (You don't need these, it's for the cloud instance payments)
NEXT_PUBLIC_STRIPE_IS_ACTIVE=
STRIPE_SECRET_KEY=
PRICE_ID=
NEXT_PUBLIC_TRIAL_PERIOD_DAYS=
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
BASE_URL=http://localhost:3000
NEXT_PUBLIC_PRICING=
# Docker postgres settings
POSTGRES_PASSWORD=
-17
View File
@@ -1,17 +0,0 @@
# How to contribute
> **For questions/help, feature requests and bug reports please create an [issue](https://github.com/Daniel31x13/link-warden/issues) (please use the right lable).**
First off, I'm really glad you're reading this and thank you for taking the time to contribute!
1. Confirm your planned implementation fit into our project [features](https://github.com/Daniel31x13/link-warden#features) and [project roadmap](https://github.com/Daniel31x13/link-warden/wiki#project-roadmap) (wether it's adding a new feature or improving our existing code).
2. Open an [issue](https://github.com/Daniel31x13/link-warden/issues/new?assignees=&labels=contribution&template=contribution.md&title=Contribution) with your planned implementation to discuss.
3. Check in with me before starting development to make sure your work wont conflict with or duplicate existing work (by doing step 2).
4. Commit, push, and submit a PR and wait for review feedback.
5. Have patience, don't abandon your PR! We love contributors but we don't always have time to respond to notifications instantly. If you want a faster response, DM me on [Twitter](https://twitter.com/daniel31x13).
Thanks again! <3
+20
View File
@@ -0,0 +1,20 @@
---
name: Ask a Question
about: Ask about a particular topic
title: ''
labels: question
assignees: ''
---
**Is your question related to a problem or code? Please describe.**
A clear and concise description of what the problem or code is. Ex. I'm confused about how [...] works, or I'm facing an issue when [...]
**Describe what you've tried to solve this question**
Explain what steps or research you've already taken to try and understand or solve this.
**Include any code or screenshots (if applicable)**
Add any code snippets, error messages, or screenshots that might help others understand your question better.
**Additional context**
Include any additional context or details that might help get a clearer understanding of your question.
@@ -1,8 +1,8 @@
---
name: Bug report
name: Bug Report
about: Create a report to help us improve
title: ''
labels: ''
labels: bug
assignees: ''
---
@@ -1,8 +1,8 @@
---
name: Feature request
name: Feature Request
about: Suggest an idea for this project
title: ''
labels: ''
labels: enhancement
assignees: ''
---
-7
View File
@@ -1,7 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
target-branch: "dev"
+49
View File
@@ -0,0 +1,49 @@
name: Create and publish a container image on release
on:
push:
tags:
- "*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+5
View File
@@ -36,9 +36,14 @@ next-env.d.ts
# generated files and folders
/data
.idea
prisma/dev.db
# tests
/tests
/test-results/
/playwright-report/
/playwright/.cache/
# docker
pgdata
+23
View File
@@ -0,0 +1,23 @@
FROM node:18.18-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
RUN mkdir /data
WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
# Increase timeout to pass github actions arm64 build
RUN yarn install --network-timeout 10000000
RUN npx playwright install-deps && \
apt-get clean && \
yarn cache clean
COPY . .
RUN yarn prisma generate && \
yarn build
CMD yarn prisma migrate deploy && yarn start
+97 -8
View File
@@ -1,13 +1,102 @@
<div align="center">
<h1>
Linkwarden
</h1>
<h3>Collect, Organize, and Preserve Links with Seamless Collaboration!</h3>
<img src="./assets/logo.png" width="100px" />
<h1>Linkwarden</h1>
Rebuilding things from ground up (Almost ready).
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat-square" alt="Discord"></a>
<img alt="GitHub commits since latest release (by SemVer including pre-releases)" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/v1.1.0/dev">
<img src="https://img.shields.io/github/languages/top/linkwarden/linkwarden?style=flat-square" alt="Top Language">
<img src="https://img.shields.io/github/stars/linkwarden/linkwarden?style=flat-square" alt="Github Stars">
To get a better sense at what this repository is all about, you can check out the old version *[here](https://github.com/linkwarden/linkwarden-old)*.
<h3>Thanks for your patience!</h3>
</div>
<div align='center'>
[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
</div>
## Intro & motivation
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
<img src="./assets/showcase_image.png" />
> **Note**
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer [self-hosting](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features.
<details>
<summary><b>A bit of a "history"</b></summary>
Linkwarden has been completely rebuilt and redesigned from ground up, so pretty much the only thing it has in common with its predecessor is the idea behind it - bookmark management.
**What happened to the old version?**
We highly recommend that you don't use the old version because it is no longer maintained and has far fewer features. However, if you still want to check it out, we've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
</details>
## Features
- 📸 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.
- 👥 Collaborate on gathering links in a collection.
- 🔐 Customize the permissions of each member.
- 🌐 Share your collected links with the world.
- 📌 Pin your favorite links to dashboard.
- 🔍 Full text search, filter and sort for easy retrieval.
- 📱 Responsive design and supports most modern browsers.
- 🌓 Dark/Light mode support.
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import your bookmarks from other browsers.
- ⚡️ Powerful API.
## Suggestions
We _usually_ go after the [popular suggestions](https://github.com/linkwarden/linkwarden/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc). Feel free to open a [new issue](https://github.com/linkwarden/linkwarden/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.md&title=) to suggest one - others might be interested too! :)
## Roadmap
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
## Docs
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
## Main Tech Stack
- NextJS
- TypeScript
- Tailwind
- Prisma
- Zustand
## Development
If you want to contribute, Thanks! Start by checking our [public roadmap](https://github.com/orgs/linkwarden/projects/1), there you'll see a [README for contributers](https://github.com/orgs/linkwarden/projects/1?pane=issue&itemId=34708277) for the rest of the info on how to contribute to this repo.
## Security
If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks!
## Screenshots
<div align="center">
<img src="./assets/collections.png" height="150" />
<img src="./assets/collaborators.png" height="150" />
<img src="./assets/link_details.png" height="150" />
</div>
## Support ❤
Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well!
Here are the other ways to support/cheer this project:
- Starring this repository.
- Joining us on [Discord](https://discord.com/invite/CtuYV47nuJ).
- Referring Linkwarden to a friend.
If you did any of the above, Thanksss! Otherwise thanks.
Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

+35
View File
@@ -0,0 +1,35 @@
import { faClose } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Link from "next/link";
import React, { MouseEventHandler } from "react";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
return (
<div className="fixed w-full z-20 dark:bg-neutral-900 bg-white">
<div className="w-full h-10 rainbow flex items-center justify-center">
<div className="w-fit font-semibold">
🎉{" "}
<Link
href="https://blog.linkwarden.app/releases/v2.0"
target="_blank"
className="underline hover:opacity-50 duration-100"
>
Linkwarden v2.0
</Link>{" "}
is now out! 🥳
</div>
<button
className="fixed top-3 right-3 hover:opacity-50 duration-100"
onClick={toggleAnnouncementBar}
>
<FontAwesomeIcon icon={faClose} className="w-4 h-4" />
</button>
</div>
</div>
);
}
+4 -4
View File
@@ -12,7 +12,7 @@ type Props = {
export default function Checkbox({ label, state, className, onClick }: Props) {
return (
<label
className={`cursor-pointer flex items-center gap-2 text-sky-700 ${className}`}
className={`cursor-pointer flex items-center gap-2 ${className || ""}`}
>
<input
type="checkbox"
@@ -22,13 +22,13 @@ export default function Checkbox({ label, state, className, onClick }: Props) {
/>
<FontAwesomeIcon
icon={faSquareCheck}
className="w-5 h-5 text-sky-700 peer-checked:block hidden"
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:block hidden"
/>
<FontAwesomeIcon
icon={faSquare}
className="w-5 h-5 text-sky-700 peer-checked:hidden block"
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:hidden block"
/>
<span className="text-sky-900 rounded select-none">{label}</span>
<span className="rounded select-none">{label}</span>
</label>
);
}
+14 -2
View File
@@ -4,6 +4,8 @@ type Props = {
children: ReactNode;
onClickOutside: Function;
className?: string;
style?: React.CSSProperties;
onMount?: (rect: DOMRect) => void;
};
function useOutsideAlerter(
@@ -30,12 +32,22 @@ export default function ClickAwayHandler({
children,
onClickOutside,
className,
style,
onMount,
}: Props) {
const wrapperRef = useRef(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
useOutsideAlerter(wrapperRef, onClickOutside);
useEffect(() => {
if (wrapperRef.current && onMount) {
const rect = wrapperRef.current.getBoundingClientRect();
onMount(rect); // Pass the bounding rectangle to the parent
}
}, []);
return (
<div ref={wrapperRef} className={className}>
<div ref={wrapperRef} className={className} style={style}>
{children}
</div>
);
+83 -53
View File
@@ -1,5 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsis, faLink } from "@fortawesome/free-solid-svg-icons";
import { faEllipsis, faGlobe, faLink } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Dropdown from "./Dropdown";
@@ -8,15 +8,25 @@ import ProfilePhoto from "./ProfilePhoto";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import useModalStore from "@/store/modals";
import usePermissions from "@/hooks/usePermissions";
import { useTheme } from "next-themes";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
className?: string;
};
type DropdownTrigger =
| {
x: number;
y: number;
}
| false;
export default function CollectionCard({ collection, className }: Props) {
const { setModal } = useModalStore();
const { theme } = useTheme();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
{
@@ -26,66 +36,86 @@ export default function CollectionCard({ collection, className }: Props) {
}
);
const [expandDropdown, setExpandDropdown] = useState(false);
const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
const permissions = usePermissions(collection.id as number);
return (
<div
className={`bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% to-white to-100% self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none group relative ${className}`}
>
<>
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id={"expand-dropdown" + collection.id}
className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 duration-100 p-1"
style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
theme === "dark" ? "#262626" : "#f3f4f6"
} 50%, ${theme === "dark" ? "#262626" : "#f9fafb"} 100%)`,
}}
className={`border border-solid border-sky-100 dark:border-neutral-700 self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none hover:opacity-80 group relative ${
className || ""
}`}
>
<FontAwesomeIcon
icon={faEllipsis}
<div
onClick={(e) => setExpandDropdown({ x: e.clientX, y: e.clientY })}
id={"expand-dropdown" + collection.id}
className="w-5 h-5 text-gray-500"
/>
</div>
<Link
href={`/collections/${collection.id}`}
className="flex flex-col gap-2 justify-between min-h-[12rem] h-full select-none p-5"
>
<p className="text-2xl font-bold capitalize text-sky-700 break-words line-clamp-3 w-4/5">
{collection.name}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center w-full">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={`/api/avatar/${e.userId}?${Date.now()}`}
className="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{collection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-700 border-sky-100 -mr-3">
+{collection.members.length - 4}
</div>
) : null}
</div>
<div className="text-right w-40">
<div className="text-sky-700 font-bold text-sm flex justify-end gap-1 items-center">
<FontAwesomeIcon icon={faLink} className="w-5 h-5 text-sky-500" />
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-600">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p className="font-bold text-xs">{formattedDate}</p>
</div>
</div>
className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
id={"expand-dropdown" + collection.id}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
</Link>
<Link
href={`/collections/${collection.id}`}
className="flex flex-col gap-2 justify-between min-h-[12rem] h-full select-none p-5"
>
<p className="text-2xl capitalize text-black dark:text-white break-words line-clamp-3 w-4/5">
{collection.name}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center w-full">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
className="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{collection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700 -mr-3">
+{collection.members.length - 4}
</div>
) : null}
</div>
<div className="text-right w-40">
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? (
<FontAwesomeIcon
icon={faGlobe}
title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
<FontAwesomeIcon
icon={faLink}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p className="font-bold text-xs">{formattedDate}</p>
</div>
</div>
</div>
</Link>
</div>
{expandDropdown ? (
<Dropdown
points={{ x: expandDropdown.x, y: expandDropdown.y }}
items={[
permissions === true
? {
@@ -141,9 +171,9 @@ export default function CollectionCard({ collection, className }: Props) {
if (target.id !== "expand-dropdown" + collection.id)
setExpandDropdown(false);
}}
className="absolute top-[3.2rem] right-5 z-10"
className="w-fit"
/>
) : null}
</div>
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = {
name: string;
value: number;
icon: IconProp;
};
export default function dashboardItem({ name, value, icon }: Props) {
return (
<div className="flex gap-4 items-end">
<div className="p-4 bg-sky-500 bg-opacity-20 dark:bg-opacity-10 rounded-xl select-none">
<FontAwesomeIcon
icon={icon}
className="w-8 h-8 text-sky-500 dark:text-sky-500"
/>
</div>
<div className="flex flex-col justify-center">
<p className="text-gray-500 dark:text-gray-400 text-sm tracking-wider">
{name}
</p>
<p className="font-thin text-6xl text-sky-500 dark:text-sky-500">
{value}
</p>
</div>
</div>
);
}
+60 -7
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import React, { MouseEventHandler, useEffect, useState } from "react";
import ClickAwayHandler from "./ClickAwayHandler";
type MenuItem =
@@ -19,19 +19,72 @@ type Props = {
onClickOutside: Function;
className?: string;
items: MenuItem[];
points?: { x: number; y: number };
style?: React.CSSProperties;
};
export default function Dropdown({ onClickOutside, className, items }: Props) {
return (
export default function Dropdown({
points,
onClickOutside,
className,
items,
}: Props) {
const [pos, setPos] = useState<{ x: number; y: number }>();
const [dropdownHeight, setDropdownHeight] = useState<number>();
const [dropdownWidth, setDropdownWidth] = useState<number>();
function convertRemToPixels(rem: number) {
return (
rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
);
}
useEffect(() => {
if (points) {
let finalX = points.x;
let finalY = points.y;
// Check for x-axis overflow (left side)
if (dropdownWidth && points.x + dropdownWidth > window.innerWidth) {
finalX = points.x - dropdownWidth;
}
// Check for y-axis overflow (bottom side)
if (dropdownHeight && points.y + dropdownHeight > window.innerHeight) {
finalY =
window.innerHeight -
(dropdownHeight + (window.innerHeight - points.y));
}
setPos({ x: finalX, y: finalY });
}
}, [points, dropdownHeight]);
return !points || pos ? (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${className} py-1 shadow-md border border-sky-100 bg-gray-50 rounded-md flex flex-col z-20`}
className={`${
className || ""
} py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 duration-100">
<p className="text-sky-900 select-none">{e.name}</p>
<div className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 dark:hover:bg-neutral-700 duration-100">
<p className="text-black dark:text-white select-none">{e.name}</p>
</div>
</div>
);
@@ -49,5 +102,5 @@ export default function Dropdown({ onClickOutside, className, items }: Props) {
);
})}
</ClickAwayHandler>
);
) : null;
}
+21 -4
View File
@@ -1,12 +1,17 @@
import React, { SetStateAction } from "react";
import ClickAwayHandler from "./ClickAwayHandler";
import Checkbox from "./Checkbox";
import { LinkSearchFilter } from "@/types/global";
type Props = {
setFilterDropdown: (value: SetStateAction<boolean>) => void;
setSearchFilter: Function;
searchFilter: LinkSearchFilter;
searchFilter: {
name: boolean;
url: boolean;
description: boolean;
textContent: boolean;
tags: boolean;
};
};
export default function FilterSearchDropdown({
@@ -20,9 +25,11 @@ export default function FilterSearchDropdown({
const target = e.target as HTMLInputElement;
if (target.id !== "filter-dropdown") setFilterDropdown(false);
}}
className="absolute top-8 right-0 border border-sky-100 shadow-md bg-gray-50 rounded-md p-2 z-20 w-40"
className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-40"
>
<p className="mb-2 text-sky-900 text-center font-semibold">Filter by</p>
<p className="mb-2 text-black dark:text-white text-center font-semibold">
Filter by
</p>
<div className="flex flex-col gap-2">
<Checkbox
label="Name"
@@ -48,6 +55,16 @@ export default function FilterSearchDropdown({
})
}
/>
<Checkbox
label="Text Content"
state={searchFilter.textContent}
onClick={() =>
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
})
}
/>
<Checkbox
label="Tags"
state={searchFilter.tags}
@@ -45,7 +45,8 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
return (
<Select
isClearable
placeholder="Default: Unnamed Collection"
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
+2
View File
@@ -28,6 +28,8 @@ export default function TagSelection({ onChange, defaultValue }: Props) {
return (
<CreatableSelect
isClearable
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
-4
View File
@@ -36,10 +36,6 @@ export const styles: StylesConfig = {
...styles,
cursor: "pointer",
}),
clearIndicator: (styles) => ({
...styles,
visibility: "hidden",
}),
placeholder: (styles) => ({
...styles,
borderColor: "black",
+117 -70
View File
@@ -19,6 +19,9 @@ import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import isValidUrl from "@/lib/client/isValidUrl";
import Link from "next/link";
import unescapeString from "@/lib/client/unescapeString";
import { useRouter } from "next/router";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@@ -26,12 +29,21 @@ type Props = {
className?: string;
};
type DropdownTrigger =
| {
x: number;
y: number;
}
| false;
export default function LinkCard({ link, count, className }: Props) {
const { setModal } = useModalStore();
const router = useRouter();
const permissions = usePermissions(link.collection.id as number);
const [expandDropdown, setExpandDropdown] = useState(false);
const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
const { collections } = useCollectionStore();
@@ -62,7 +74,7 @@ export default function LinkCard({ link, count, className }: Props) {
);
}, [collections, links]);
const { removeLink, updateLink } = useLinkStore();
const { removeLink, updateLink, getLink } = useLinkStore();
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
@@ -82,10 +94,29 @@ export default function LinkCard({ link, count, className }: Props) {
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
};
const updateArchive = async () => {
const load = toast.loading("Sending request...");
setExpandDropdown(false);
const response = await fetch(`/api/v1/links/${link.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(`Link is being archived...`);
getLink(link.id as number);
} else toast.error(data.response);
};
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const response = await removeLink(link);
const response = await removeLink(link.id as number);
toast.dismiss(load);
@@ -105,87 +136,100 @@ export default function LinkCard({ link, count, className }: Props) {
);
return (
<div
className={`bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow hover:shadow-none cursor-pointer duration-100 rounded-2xl relative group ${className}`}
>
{(permissions === true ||
permissions?.canUpdate ||
permissions?.canDelete) && (
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id={"expand-dropdown" + link.id}
className="text-gray-500 inline-flex rounded-md cursor-pointer hover:bg-slate-200 absolute right-5 top-5 z-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
title="More"
className="w-5 h-5"
id={"expand-dropdown" + link.id}
/>
</div>
)}
<>
<div
onClick={() => {
setModal({
modal: "LINK",
state: true,
method: "UPDATE",
isOwnerOrMod:
permissions === true || (permissions?.canUpdate as boolean),
active: link,
});
}}
className="flex items-start gap-5 sm:gap-10 h-full w-full p-5"
className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${
className || ""
}`}
>
{url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={64}
height={64}
alt=""
className="blur-sm absolute w-16 group-hover:opacity-80 duration-100 rounded-md bottom-5 right-5 opacity-60 select-none"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
{(permissions === true ||
permissions?.canUpdate ||
permissions?.canDelete) && (
<div
onClick={(e) => {
setExpandDropdown({ x: e.clientX, y: e.clientY });
}}
/>
id={"expand-dropdown" + link.id}
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
title="More"
className="w-5 h-5"
id={"expand-dropdown" + link.id}
/>
</div>
)}
<div className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1">
<p className="text-sm text-sky-500 font-bold">{count + 1}.</p>
<p className="text-lg text-sky-700 font-bold truncate capitalize w-full pr-8">
{link.name || link.description}
</p>
</div>
<div className="flex gap-3 items-center my-3">
<div className="flex items-center gap-1 w-full pr-20">
<div
onClick={() => router.push("/links/" + link.id)}
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5"
>
{url && account.displayLinkIcons && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={64}
height={64}
alt=""
className={`${
account.blurredFavicons ? "blur-sm " : ""
}absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none`}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
)}
<div className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1">
<p className="text-sm text-gray-500 dark:text-gray-300">
{count + 1}
</p>
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8">
{unescapeString(link.name || link.description)}
</p>
</div>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit my-1 hover:opacity-70 duration-100"
>
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }}
/>
<p className="text-sky-900 truncate capitalize">
<p className="text-black dark:text-white truncate capitalize w-full">
{collection?.name}
</p>
</Link>
<Link
href={link.url}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100"
>
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</Link>
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
<div className="flex items-center gap-1 w-full pr-20 text-gray-500">
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</div>
<div className="flex items-center gap-1 text-gray-500">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
</div>
</div>
{expandDropdown ? (
<Dropdown
points={{ x: expandDropdown.x, y: expandDropdown.y }}
items={[
permissions === true
? {
@@ -204,15 +248,18 @@ export default function LinkCard({ link, count, className }: Props) {
modal: "LINK",
state: true,
method: "UPDATE",
isOwnerOrMod:
permissions === true || permissions?.canUpdate,
active: link,
defaultIndex: 1,
});
setExpandDropdown(false);
},
}
: undefined,
permissions === true
? {
name: "Refresh Formats",
onClick: updateArchive,
}
: undefined,
permissions === true || permissions?.canDelete
? {
name: "Delete",
@@ -225,9 +272,9 @@ export default function LinkCard({ link, count, className }: Props) {
if (target.id !== "expand-dropdown" + link.id)
setExpandDropdown(false);
}}
className="absolute top-12 right-5 w-36"
className="w-40"
/>
) : null}
</div>
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { faFolder, faLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/client/isValidUrl";
import A from "next/link";
import unescapeString from "@/lib/client/unescapeString";
import { Link } from "@prisma/client";
type Props = {
link?: Partial<Link>;
className?: string;
settings: {
blurredFavicons: boolean;
displayLinkIcons: boolean;
};
};
export default function LinkPreview({ link, className, settings }: Props) {
if (!link) {
link = {
name: "Linkwarden",
url: "https://linkwarden.app",
createdAt: Date.now() as unknown as Date,
};
}
let shortendURL;
try {
shortendURL = new URL(link.url as string).host.toLowerCase();
} catch (error) {
console.log(error);
}
const url = isValidUrl(link.url as string)
? new URL(link.url as string)
: undefined;
const formattedDate = new Date(link.createdAt as Date).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
return (
<>
<div
className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${
className || ""
}`}
>
<div className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5">
{url && settings?.displayLinkIcons && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={64}
height={64}
alt=""
className={`${
settings.blurredFavicons ? "blur-sm " : ""
}absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none`}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
)}
<div className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1">
<p className="text-sm text-gray-500 dark:text-gray-300">{1}</p>
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8">
{unescapeString(link.name as string)}
</p>
</div>
<div className="flex items-center gap-1 max-w-full w-fit my-1 hover:opacity-70 duration-100">
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow text-sky-400"
/>
<p className="text-black dark:text-white truncate capitalize w-full">
Landing Pages
</p>
</div>
<A
href={link.url as string}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100"
>
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</A>
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
</div>
</div>
</div>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPen,
faBoxesStacked,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useModalStore from "@/store/modals";
import useLinkStore from "@/store/links";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
type Props = {
className?: string;
onClick?: Function;
};
export default function SettingsSidebar({ className, onClick }: Props) {
const session = useSession();
const userId = session.data?.user.id;
const { setModal } = useModalStore();
const { links, removeLink } = useLinkStore();
const { collections } = useCollectionStore();
const [linkCollection, setLinkCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const router = useRouter();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection.id));
}, [link]);
return (
<div
className={`dark:bg-neutral-900 bg-white h-full lg:w-10 w-62 overflow-y-auto lg:p-0 p-5 border-solid border-white border dark:border-neutral-900 dark:lg:border-r-neutral-900 lg:border-r-white border-r-sky-100 dark:border-r-neutral-700 z-20 flex flex-col gap-5 lg:justify-center justify-start ${
className || ""
}`}
>
<div className="flex flex-col gap-5">
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
<div
title="Edit"
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "UPDATE",
})
: undefined;
onClick && onClick();
}}
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Edit
</p>
</div>
) : undefined}
<div
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined;
onClick && onClick();
}}
title="Preserved Formats"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Preserved Formats
</p>
</div>
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
<div
onClick={() => {
if (link?.id) {
removeLink(link.id);
router.back();
onClick && onClick();
}
}}
title="Delete"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Delete
</p>
</div>
) : undefined}
</div>
</div>
);
}
+8 -13
View File
@@ -6,11 +6,11 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import RequiredBadge from "../../RequiredBadge";
import SubmitButton from "@/components/SubmitButton";
import { HexColorPicker } from "react-colorful";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput";
type Props = {
toggleCollectionModal: Function;
@@ -60,23 +60,18 @@ export default function CollectionInfo({
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="text-sm text-sky-700 mb-2">
Name
<RequiredBadge />
</p>
<p className="text-black dark:text-white mb-2">Name</p>
<div className="flex flex-col gap-3">
<input
<TextInput
value={collection.name}
placeholder="e.g. Example Collection"
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
type="text"
placeholder="e.g. Example Collection"
className="w-full rounded-md p-3 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<div className="color-picker flex justify-between">
<div className="flex flex-col justify-between items-center w-32">
<p className="text-sm w-full text-sky-700 mb-2">Icon Color</p>
<p className="w-full text-black dark:text-white mb-2">Color</p>
<div style={{ color: collection.color }}>
<FontAwesomeIcon
icon={faFolder}
@@ -84,7 +79,7 @@ export default function CollectionInfo({
/>
</div>
<div
className="py-1 px-2 rounded-md text-xs font-semibold cursor-pointer text-sky-700 hover:bg-slate-200 duration-100"
className="py-1 px-2 rounded-md text-xs font-semibold cursor-pointer text-black dark:text-white hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
@@ -101,9 +96,9 @@ export default function CollectionInfo({
</div>
<div className="w-full">
<p className="text-sm text-sky-700 mb-2">Description</p>
<p className="text-black dark:text-white mb-2">Description</p>
<textarea
className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-white p-3 outline-none border-sky-100 focus:border-sky-700"
className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-gray-50 dark:bg-neutral-950 p-2 outline-none border-sky-100 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600"
placeholder="The purpose of this Collection..."
value={collection.description}
onChange={(e) =>
@@ -9,6 +9,7 @@ import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import TextInput from "@/components/TextInput";
type Props = {
toggleDeleteCollectionModal: Function;
@@ -50,7 +51,7 @@ export default function DeleteCollection({
<p className="text-red-500 font-bold text-center">Warning!</p>
<div className="max-h-[20rem] overflow-y-auto">
<div className="text-gray-500">
<div className="text-black dark:text-white">
<p>
Please note that deleting the collection will permanently remove
all its contents, including the following:
@@ -81,25 +82,24 @@ export default function DeleteCollection({
</div>
<div className="flex flex-col gap-3">
<p className="text-sky-900 select-none text-center">
<p className="text-black dark:text-white text-center">
To confirm, type &quot;
<span className="font-bold text-sky-700">{collection.name}</span>
<span className="font-bold">{collection.name}</span>
&quot; in the box below:
</p>
<input
autoFocus
<TextInput
autoFocus={true}
value={inputField}
onChange={(e) => setInputField(e.target.value)}
type="text"
placeholder={`Type "${collection.name}" Here.`}
className="w-72 sm:w-96 rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
className="w-3/4 mx-auto"
/>
</div>
</>
) : (
<p className="text-gray-500">
Click the button below to leave the current collection:
<p className="text-black dark:text-white">
Click the button below to leave the current collection.
</p>
)}
@@ -107,9 +107,9 @@ export default function DeleteCollection({
className={`mx-auto mt-2 text-white flex items-center gap-2 py-2 px-5 rounded-md select-none font-bold duration-100 ${
permissions === true
? inputField === collection.name
? "bg-red-500 hover:bg-red-400 cursor-pointer"
: "cursor-not-allowed bg-red-300"
: "bg-red-500 hover:bg-red-400 cursor-pointer"
? "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
: "cursor-not-allowed bg-red-300 dark:bg-red-900"
: "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
}`}
onClick={submit}
>
+54 -70
View File
@@ -9,7 +9,6 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import { useSession } from "next-auth/react";
import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Checkbox from "../../Checkbox";
import SubmitButton from "@/components/SubmitButton";
@@ -17,6 +16,8 @@ import ProfilePhoto from "@/components/ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import getPublicUserData from "@/lib/client/getPublicUserData";
import TextInput from "@/components/TextInput";
import useAccountStore from "@/store/account";
type Props = {
toggleCollectionModal: Function;
@@ -33,31 +34,25 @@ export default function TeamManagement({
collection,
method,
}: Props) {
const { account } = useAccountStore();
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [member, setMember] = useState<Member>({
canCreate: false,
canUpdate: false,
canDelete: false,
user: {
name: "",
username: "",
},
});
const [memberUsername, setMemberUsername] = useState("");
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
image: "",
});
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData({ id: collection.ownerId });
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
@@ -66,8 +61,6 @@ export default function TeamManagement({
const { addCollection, updateCollection } = useCollectionStore();
const session = useSession();
const setMemberState = (newMember: Member) => {
if (!collection) return null;
@@ -76,15 +69,7 @@ export default function TeamManagement({
members: [...collection.members, newMember],
});
setMember({
canCreate: false,
canUpdate: false,
canDelete: false,
user: {
name: "",
username: "",
},
});
setMemberUsername("");
};
const [submitLoader, setSubmitLoader] = useState(false);
@@ -117,7 +102,7 @@ export default function TeamManagement({
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{permissions === true && (
<>
<p className="text-sm text-sky-700">Make Public</p>
<p className="text-black dark:text-white">Make Public</p>
<Checkbox
label="Make this a public collection."
@@ -127,7 +112,7 @@ export default function TeamManagement({
}
/>
<p className="text-gray-500 text-sm">
<p className="text-gray-500 dark:text-gray-300 text-sm">
This will let <b>Anyone</b> to view this collection.
</p>
</>
@@ -135,7 +120,7 @@ export default function TeamManagement({
{collection.isPublic ? (
<div>
<p className="text-sm text-sky-700 mb-2">
<p className="text-black dark:text-white mb-2">
Public Link (Click to copy)
</p>
<div
@@ -148,52 +133,47 @@ export default function TeamManagement({
console.log(err);
}
}}
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-3 border-sky-100 border-solid border outline-none hover:border-sky-700 duration-100 cursor-text"
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 dark:bg-neutral-950 border-sky-100 dark:border-neutral-700 border-solid border outline-none hover:border-sky-300 dark:hover:border-sky-600 duration-100 cursor-text"
>
{publicCollectionURL}
</div>
</div>
) : null}
{permissions !== true && collection.isPublic && <hr />}
{permissions !== true && collection.isPublic && (
<hr className="mb-3 border border-sky-100 dark:border-neutral-700" />
)}
{permissions === true && (
<>
<p className="text-sm text-sky-700">Member Management</p>
<p className="text-black dark:text-white">Member Management</p>
<div className="flex items-center gap-2">
<input
value={member.user.username || ""}
onChange={(e) => {
setMember({
...member,
user: { ...member.user, username: e.target.value },
});
}}
<TextInput
value={memberUsername || ""}
placeholder="Username (without the '@')"
onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
session.data?.user.username as string,
member.user.username || "",
account.username as string,
memberUsername || "",
collection,
setMemberState
)
}
type="text"
placeholder="Username (without the '@')"
className="w-full rounded-md p-3 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<div
onClick={() =>
addMemberToCollection(
session.data?.user.username as string,
member.user.username || "",
account.username as string,
memberUsername || "",
collection,
setMemberState
)
}
className="flex items-center justify-center bg-sky-700 hover:bg-sky-600 duration-100 text-white w-12 h-12 p-3 rounded-md cursor-pointer"
className="flex items-center justify-center bg-sky-700 hover:bg-sky-600 duration-100 text-white w-10 h-10 p-2 rounded-md cursor-pointer"
>
<FontAwesomeIcon icon={faUserPlus} className="w-5 h-5" />
</div>
@@ -203,7 +183,7 @@ export default function TeamManagement({
{collection?.members[0]?.user && (
<>
<p className="text-center text-gray-500 text-xs sm:text-sm">
<p className="text-center text-gray-500 dark:text-gray-300 text-xs sm:text-sm">
(All Members have <b>Read</b> access to this collection.)
</p>
<div className="flex flex-col gap-3 rounded-md">
@@ -213,12 +193,12 @@ export default function TeamManagement({
return (
<div
key={i}
className="relative border p-2 rounded-md border-sky-100 flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
className="relative border p-2 rounded-md border-sky-100 dark:border-neutral-700 flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
>
{permissions === true && (
<FontAwesomeIcon
icon={faClose}
className="absolute right-2 top-2 text-gray-500 h-4 hover:text-red-500 duration-100 cursor-pointer"
className="absolute right-2 top-2 text-gray-500 dark:text-gray-300 h-4 hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
title="Remove Member"
onClick={() => {
const updatedMembers = collection.members.filter(
@@ -235,27 +215,29 @@ export default function TeamManagement({
)}
<div className="flex items-center gap-2">
<ProfilePhoto
src={`/api/avatar/${e.userId}?${Date.now()}`}
src={e.user.image ? e.user.image : undefined}
className="border-[3px]"
/>
<div>
<p className="text-sm font-bold text-sky-700">
<p className="text-sm font-bold text-black dark:text-white">
{e.user.name}
</p>
<p className="text-sky-900">@{e.user.username}</p>
<p className="text-gray-500 dark:text-gray-300">
@{e.user.username}
</p>
</div>
</div>
<div className="flex sm:block items-center gap-5 min-w-[10rem]">
<div className="flex sm:block items-center justify-between gap-5 min-w-[10rem]">
<div>
<p
className={`font-bold text-sm text-sky-700 ${
className={`font-bold text-sm text-black dark:text-white ${
permissions === true ? "" : "mb-2"
}`}
>
Permissions
</p>
{permissions === true && (
<p className="text-xs text-gray-500 mb-2">
<p className="text-xs text-gray-500 dark:text-gray-300 mb-2">
(Click to toggle.)
</p>
)}
@@ -265,7 +247,7 @@ export default function TeamManagement({
!e.canCreate &&
!e.canUpdate &&
!e.canDelete ? (
<p className="text-sm text-gray-500">
<p className="text-sm text-gray-500 dark:text-gray-300">
Has no permissions.
</p>
) : (
@@ -305,11 +287,11 @@ export default function TeamManagement({
}}
/>
<span
className={`text-sky-900 peer-checked:bg-sky-700 text-sm ${
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} peer-checked:text-white rounded p-1 select-none`}
} rounded p-1 select-none`}
>
Create
</span>
@@ -350,11 +332,11 @@ export default function TeamManagement({
}}
/>
<span
className={`text-sky-900 peer-checked:bg-sky-700 text-sm ${
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} peer-checked:text-white rounded p-1 select-none`}
} rounded p-1 select-none`}
>
Update
</span>
@@ -395,11 +377,11 @@ export default function TeamManagement({
}}
/>
<span
className={`text-sky-900 peer-checked:bg-sky-700 text-sm ${
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true
? "hover:bg-slate-200 duration-75"
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: ""
} peer-checked:text-white rounded p-1 select-none`}
} rounded p-1 select-none`}
>
Delete
</span>
@@ -415,17 +397,17 @@ export default function TeamManagement({
)}
<div
className="relative border px-2 rounded-md border-sky-100 flex min-h-[7rem] sm:min-h-[5rem] gap-2 justify-between"
className="relative border px-2 rounded-md border-sky-100 dark:border-neutral-700 flex min-h-[7rem] sm:min-h-[5rem] gap-2 justify-between"
title={`'@${collectionOwner.username}' is the owner of this collection.`}
>
<div className="flex items-center gap-2">
<ProfilePhoto
src={`/api/avatar/${collection.ownerId}?${Date.now()}`}
src={collectionOwner.image ? collectionOwner.image : undefined}
className="border-[3px]"
/>
<div>
<div className="flex items-center gap-1">
<p className="text-sm font-bold text-sky-700">
<p className="text-sm font-bold text-black dark:text-white">
{collectionOwner.name}
</p>
<FontAwesomeIcon
@@ -433,13 +415,15 @@ export default function TeamManagement({
className="w-3 h-3 text-yellow-500"
/>
</div>
<p className="text-sky-900">@{collectionOwner.username}</p>
<p className="text-gray-500 dark:text-gray-300">
@{collectionOwner.username}
</p>
</div>
</div>
<div className="flex flex-col justify-center min-w-[10rem]">
<p className={`font-bold text-sm text-sky-700`}>Permissions</p>
<p className="text-sky-700">Full Access (Owner)</p>
<div className="flex flex-col justify-center min-w-[10rem] text-black dark:text-white">
<p className={`font-bold text-sm`}>Permissions</p>
<p>Full Access (Owner)</p>
</div>
</div>
+10 -8
View File
@@ -46,17 +46,19 @@ export default function CollectionModal({
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="text-xl text-sky-700 text-center">New Collection</p>
<p className="text-xl text-black dark:text-white text-center">
New Collection
</p>
)}
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-sky-700">
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-black dark:text-white">
{method === "UPDATE" && (
<>
{isOwner && (
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
Collection Info
@@ -65,8 +67,8 @@ export default function CollectionModal({
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
{isOwner ? "Share & Collaborate" : "View Team"}
@@ -74,8 +76,8 @@ export default function CollectionModal({
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none"
}
>
{isOwner ? "Delete Collection" : "Leave Collection"}
+151 -88
View File
@@ -4,14 +4,16 @@ import TagSelection from "@/components/InputSelect/TagSelection";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import useLinkStore from "@/store/links";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import RequiredBadge from "../../RequiredBadge";
import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
import SubmitButton from "../../SubmitButton";
import { toast } from "react-hot-toast";
import Link from "next/link";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props =
| {
@@ -32,6 +34,10 @@ export default function AddOrEditLink({
}: Props) {
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(
method === "UPDATE" ? true : false
);
const { data } = useSession();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>(
@@ -40,6 +46,10 @@ export default function AddOrEditLink({
url: "",
description: "",
tags: [],
screenshotPath: "",
pdfPath: "",
readabilityPath: "",
textContent: "",
collection: {
name: "",
ownerId: data?.user.id as number,
@@ -50,22 +60,35 @@ export default function AddOrEditLink({
const { updateLink, addLink } = useLinkStore();
const router = useRouter();
const { collections } = useCollectionStore();
useEffect(() => {
if (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (method === "CREATE") {
if (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (currentCollection && currentCollection.ownerId)
if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
setLink({
...link,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...link,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
// id: ,
name: "Unorganized",
ownerId: data?.user.id as number,
},
});
}
@@ -114,95 +137,135 @@ export default function AddOrEditLink({
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{method === "UPDATE" ? (
<p
className="text-gray-500 my-2 text-center truncate w-full"
<div
className="text-gray-500 dark:text-gray-300 break-all w-full flex gap-2"
title={link.url}
>
<Link href={link.url} target="_blank" className=" font-bold">
<FontAwesomeIcon icon={faLink} className="w-6 h-6" />
<Link href={link.url} target="_blank" className="w-full">
{link.url}
</Link>
</p>
</div>
) : null}
{method === "CREATE" ? (
<div>
<p className="text-sm text-sky-700 mb-2 font-bold">
Address (URL)
<RequiredBadge />
</p>
<input
value={link.url}
onChange={(e) => setLink({ ...link, url: e.target.value })}
type="text"
placeholder="e.g. http://example.com/"
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="text-black dark:text-white mb-2">Address (URL)</p>
<TextInput
value={link.url}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/"
/>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="text-black dark:text-white mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={
link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
}
/>
) : null}
</div>
</div>
) : null}
<hr />
<div className="grid sm:grid-cols-2 gap-3">
) : undefined}
{optionsExpanded ? (
<div>
<p className="text-sm text-sky-700 mb-2">Collection</p>
<CollectionSelection
onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={
link.collection.name && link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: undefined
}
/>
{/* <hr className="mb-3 border border-sky-100 dark:border-neutral-700" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}>
<p className="text-black dark:text-white mb-2">Name</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
/>
</div>
{method === "UPDATE" ? (
<div>
<p className="text-black dark:text-white mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.name && link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
}
/>
) : undefined}
</div>
) : undefined}
<div>
<p className="text-black dark:text-white mb-2">Tags</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
/>
</div>
<div className="sm:col-span-2">
<p className="text-black dark:text-white mb-2">Description</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={
method === "CREATE"
? "Will be auto generated if nothing is provided."
: ""
}
className="resize-none w-full rounded-md p-2 border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100 dark:bg-neutral-950"
/>
</div>
</div>
</div>
) : undefined}
<div className="flex justify-between items-stretch mt-2">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`${
method === "UPDATE" ? "hidden" : ""
} rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 flex items-center px-2 w-fit text-sm`}
>
<p>{optionsExpanded ? "Hide" : "More"} Options</p>
</div>
<div>
<p className="text-sm text-sky-700 mb-2">Tags</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
/>
</div>
<div className="sm:col-span-2">
<p className="text-sm text-sky-700 mb-2">Name</p>
<input
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
type="text"
placeholder="e.g. Example Link"
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
<div className="sm:col-span-2">
<p className="text-sm text-sky-700 mb-2">Description</p>
<textarea
value={link.description}
onChange={(e) => setLink({ ...link, description: e.target.value })}
placeholder={
method === "CREATE"
? "Will be auto generated if nothing is provided."
: ""
}
className="resize-none w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
<SubmitButton
onClick={submit}
label={method === "CREATE" ? "Add" : "Save"}
icon={method === "CREATE" ? faPlus : faPenToSquare}
loading={submitLoader}
className={`${method === "CREATE" ? "" : "mx-auto"}`}
/>
</div>
<SubmitButton
onClick={submit}
label={method === "CREATE" ? "Add" : "Save"}
icon={method === "CREATE" ? faPlus : faPenToSquare}
loading={submitLoader}
className={`mx-auto mt-2`}
/>
</div>
);
}
-287
View File
@@ -1,287 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faBoxArchive,
faCloudArrowDown,
faFolder,
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import {
faCalendarDays,
faFileImage,
faFilePdf,
} from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/client/isValidUrl";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
};
export default function LinkDetails({ link }: Props) {
const [imageError, setImageError] = useState<boolean>(false);
const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
const { collections } = useCollectionStore();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections]);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const colorThief = new ColorThief();
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const rgbToHex = (r: number, g: number, b: number): string =>
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("");
useEffect(() => {
const banner = document.getElementById("link-banner");
const bannerInner = document.getElementById("link-banner-inner");
if (colorPalette && banner && bannerInner) {
banner.style.background = `linear-gradient(to right, ${rgbToHex(
colorPalette[0][0],
colorPalette[0][1],
colorPalette[0][2]
)}, ${rgbToHex(
colorPalette[1][0],
colorPalette[1][1],
colorPalette[1][2]
)})`;
bannerInner.style.background = `linear-gradient(to right, ${rgbToHex(
colorPalette[2][0],
colorPalette[2][1],
colorPalette[2][2]
)}, ${rgbToHex(
colorPalette[3][0],
colorPalette[3][1],
colorPalette[3][2]
)})`;
}
}, [colorPalette]);
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/archives/${link.collection.id}/${link.id}.${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === "pdf" ? "PDF" : "Screenshot";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{!imageError && (
<div id="link-banner" className="link-banner h-32 -mx-5 -mt-5 relative">
<div id="link-banner-inner" className="link-banner-inner"></div>
</div>
)}
<div
className={`relative flex gap-5 items-start ${!imageError && "-mt-24"}`}
>
{!imageError && url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={42}
height={42}
alt=""
id={"favicon-" + link.id}
className="select-none mt-2 rounded-md shadow border-[3px] border-white bg-white aspect-square"
draggable="false"
onLoad={(e) => {
try {
const color = colorThief.getPalette(
e.target as HTMLImageElement,
4
);
setColorPalette(color);
} catch (err) {
console.log(err);
}
}}
onError={(e) => {
setImageError(true);
}}
/>
)}
<div className="flex flex-col min-h-[3rem] justify-end drop-shadow">
<p className="text-2xl text-sky-700 capitalize break-words hyphens-auto">
{link.name}
</p>
<Link
href={link.url}
target="_blank"
rel="noreferrer"
className="text-sm text-gray-500 break-all hover:underline cursor-pointer w-fit"
>
{url ? url.host : link.url}
</Link>
</div>
</div>
<div className="flex gap-1 items-center flex-wrap">
<Link
href={`/collections/${link.collection.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
<FontAwesomeIcon
icon={faFolder}
className="w-5 h-5 drop-shadow"
style={{ color: collection?.color }}
/>
<p
title={collection?.name}
className="text-sky-900 text-lg truncate max-w-[12rem]"
>
{collection?.name}
</p>
</Link>
{link.tags.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="px-2 py-1 bg-sky-200 text-sky-700 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</p>
</Link>
))}
</div>
{link.description && (
<>
<div className="text-gray-500 max-h-[20rem] my-3 rounded-md overflow-y-auto hyphens-auto">
{link.description}
</div>
</>
)}
<div className="flex justify-between items-center">
<div className="flex items-center gap-1 text-gray-500">
<FontAwesomeIcon icon={faBoxArchive} className="w-4 h-4" />
<p className=" text-gray-500">Archived Formats:</p>
</div>
<div
className="flex items-center gap-1 text-gray-500"
title={"Created at: " + formattedDate}
>
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center p-2 border border-sky-100 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 p-2 rounded-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-gray-500">Screenshot</p>
</div>
<div className="flex text-sky-500 gap-1">
<Link
href={`/api/archives/${link.collectionId}/${link.id}.png`}
target="_blank"
rel="noreferrer"
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5"
/>
</Link>
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer"
/>
</div>
</div>
</div>
<div className="flex justify-between items-center p-2 border border-sky-100 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 p-2 rounded-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-gray-500">PDF</p>
</div>
<div className="flex text-sky-500 gap-1">
<Link
href={`/api/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
rel="noreferrer"
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5"
/>
</Link>
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer"
/>
</div>
</div>
</div>
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faCloudArrowDown,
} from "@fortawesome/free-solid-svg-icons";
import { faFileImage, faFilePdf } from "@fortawesome/free-regular-svg-icons";
import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
export default function PreservedFormats() {
const session = useSession();
const { links, getLink } = useLinkStore();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const router = useRouter();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
let interval: NodeJS.Timer | undefined;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
interval = setInterval(() => getLink(link.id as number), 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.screenshotPath, link?.pdfPath, link?.readabilityPath]);
const updateArchive = async () => {
const load = toast.loading("Sending request...");
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(`Link is being archived...`);
getLink(link?.id as number);
} else toast.error(data.response);
};
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/v1/archives/${link?.collection.id}/${link?.id}.${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === "pdf" ? "PDF" : "Screenshot";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<div className={`flex flex-col gap-3 sm:w-[35rem] w-80 pt-3`}>
{link?.screenshotPath && link?.screenshotPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">Screenshot</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300"
/>
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.png`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</Link>
</div>
</div>
) : undefined}
{link?.pdfPath && link.pdfPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">PDF</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300"
/>
</div>
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</Link>
</div>
</div>
) : undefined}
<div className="flex flex-col-reverse sm:flex-row gap-5 items-center justify-center">
{link?.collection.ownerId === session.data?.user.id ? (
<div
className={`w-full text-center bg-sky-700 p-1 rounded-md cursor-pointer select-none hover:bg-sky-600 duration-100 ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "mt-3"
: ""
}`}
onClick={() => updateArchive()}
>
<p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Formats)</p>
</div>
) : undefined}
<Link
href={`https://web.archive.org/web/${link?.url.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className={`text-gray-500 dark:text-gray-300 duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "sm:mt-3"
: ""
}`}
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-4 h-4"
/>
<p className="whitespace-nowrap">
View Latest Snapshot on archive.org
</p>
</Link>
</div>
</div>
);
}
+34 -62
View File
@@ -1,91 +1,63 @@
import { Tab } from "@headlessui/react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import AddOrEditLink from "./AddOrEditLink";
import LinkDetails from "./LinkDetails";
import PreservedFormats from "./PreservedFormats";
type Props =
| {
toggleLinkModal: Function;
method: "CREATE";
isOwnerOrMod?: boolean;
activeLink?: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
className?: string;
}
| {
toggleLinkModal: Function;
method: "UPDATE";
isOwnerOrMod: boolean;
activeLink: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
className?: string;
}
| {
toggleLinkModal: Function;
method: "FORMATS";
activeLink: LinkIncludingShortenedCollectionAndTags;
className?: string;
};
export default function LinkModal({
className,
defaultIndex,
toggleLinkModal,
isOwnerOrMod,
activeLink,
method,
}: Props) {
return (
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="text-xl text-sky-700 text-center">New Link</p>
)}
<Tab.List
className={`flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-sky-700 ${
isOwnerOrMod ? "" : "pb-8"
}`}
>
{method === "UPDATE" && isOwnerOrMod && (
<>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Link Details
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Edit Link
</Tab>
</>
)}
</Tab.List>
<Tab.Panels>
{activeLink && method === "UPDATE" && (
<Tab.Panel>
<LinkDetails link={activeLink} />
</Tab.Panel>
)}
{method === "CREATE" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Create a New Link
</p>
<AddOrEditLink toggleLinkModal={toggleLinkModal} method="CREATE" />
</>
) : undefined}
<Tab.Panel>
{activeLink && method === "UPDATE" ? (
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="UPDATE"
activeLink={activeLink}
/>
) : (
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="CREATE"
/>
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
{activeLink && method === "UPDATE" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">Edit Link</p>
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="UPDATE"
activeLink={activeLink}
/>
</>
) : undefined}
{method === "FORMATS" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Preserved Formats
</p>
<PreservedFormats />
</>
) : undefined}
</div>
);
}
-43
View File
@@ -1,43 +0,0 @@
import { useState } from "react";
import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast";
import { useRouter } from "next/router";
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
export default function PaymentPortal() {
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const submit = () => {
setSubmitLoader(true);
const load = toast.loading("Redirecting to billing portal...");
router.push(process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL as string);
};
return (
<div className="mx-auto sm:w-[35rem] w-80">
<div className="max-w-[25rem] w-full mx-auto flex flex-col gap-3 justify-between">
<p className="text-md text-gray-500">
To manage/cancel your subsciption, visit the billing portal.
</p>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Go to Billing Portal"
icon={faArrowUpRightFromSquare}
className="mx-auto mt-2"
/>
<p className="text-md text-gray-500">
If you still need help or encountered any issues, feel free to reach
out to us at:{" "}
<a className="font-semibold" href="mailto:support@linkwarden.app">
support@linkwarden.app
</a>
</p>
</div>
</div>
);
}
-113
View File
@@ -1,113 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { AccountSettings } from "@/types/global";
import useAccountStore from "@/store/account";
import { signOut, useSession } from "next-auth/react";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "@/components/SubmitButton";
import { toast } from "react-hot-toast";
type Props = {
togglePasswordFormModal: Function;
setUser: Dispatch<SetStateAction<AccountSettings>>;
user: AccountSettings;
};
export default function ChangePassword({
togglePasswordFormModal,
setUser,
user,
}: Props) {
const [newPassword, setNewPassword1] = useState("");
const [newPassword2, setNewPassword2] = useState("");
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const { update, data } = useSession();
useEffect(() => {
if (
!(newPassword == "" || newPassword2 == "") &&
newPassword === newPassword2
) {
setUser({ ...user, newPassword });
}
}, [newPassword, newPassword2]);
const submit = async () => {
if (newPassword == "" || newPassword2 == "") {
toast.error("Please fill all the fields.");
}
if (newPassword !== newPassword2)
return toast.error("Passwords do not match.");
else if (newPassword.length < 8)
return toast.error("Passwords must be at least 8 characters.");
setSubmitLoader(true);
const load = toast.loading("Applying...");
const response = await updateAccount({
...user,
});
toast.dismiss(load);
if (response.ok) {
toast.success("Settings Applied!");
if (user.email !== account.email) {
update({
id: data?.user.id,
});
signOut();
} else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
setUser({ ...user, newPassword: undefined });
togglePasswordFormModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="mx-auto sm:w-[35rem] w-80">
<div className="max-w-[25rem] w-full mx-auto flex flex-col gap-3 justify-between">
<p className="text-sm text-sky-700">New Password</p>
<input
value={newPassword}
onChange={(e) => setNewPassword1(e.target.value)}
type="password"
placeholder="••••••••••••••"
className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<p className="text-sm text-sky-700">Confirm New Password</p>
<input
value={newPassword2}
onChange={(e) => setNewPassword2(e.target.value)}
type="password"
placeholder="••••••••••••••"
className="w-full rounded-md p-3 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Apply Settings"
icon={faPenToSquare}
className="mx-auto mt-2"
/>
</div>
</div>
);
}
-127
View File
@@ -1,127 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import Checkbox from "../../Checkbox";
import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global";
import { signOut, useSession } from "next-auth/react";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "../../SubmitButton";
import { toast } from "react-hot-toast";
type Props = {
toggleSettingsModal: Function;
setUser: Dispatch<SetStateAction<AccountSettings>>;
user: AccountSettings;
};
export default function PrivacySettings({
toggleSettingsModal,
setUser,
user,
}: Props) {
const { update, data } = useSession();
const { account, updateAccount } = useAccountStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState(
user.whitelistedUsers.join(", ")
);
useEffect(() => {
setUser({
...user,
whitelistedUsers: stringToArray(whitelistedUsersTextbox),
});
}, [whitelistedUsersTextbox]);
useEffect(() => {
setUser({ ...user, newPassword: undefined });
}, []);
const stringToArray = (str: string) => {
const stringWithoutSpaces = str.replace(/\s+/g, "");
const wordsArray = stringWithoutSpaces.split(",");
return wordsArray;
};
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!");
if (user.email !== account.email) {
update({
id: data?.user.id,
});
signOut();
} else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
setUser({ ...user, newPassword: undefined });
toggleSettingsModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="flex flex-col gap-3 justify-between sm:w-[35rem] w-80">
<div>
<p className="text-sm text-sky-700 mb-2">Profile Visibility</p>
<Checkbox
label="Make profile private"
state={user.isPrivate}
className="text-sm sm:text-base"
onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })}
/>
<p className="text-gray-500 text-sm">
This will limit who can find and add you to other Collections.
</p>
{user.isPrivate && (
<div>
<p className="text-sm text-sky-700 my-2">Whitelisted Users</p>
<p className="text-gray-500 text-sm mb-3">
Please provide the Username of the users you wish to grant
visibility to your profile. Separated by comma.
</p>
<textarea
className="w-full resize-none border rounded-md duration-100 bg-white p-2 outline-none border-sky-100 focus:border-sky-700"
placeholder="Your profile is hidden from everyone right now..."
value={whitelistedUsersTextbox}
onChange={(e) => {
setWhiteListedUsersTextbox(e.target.value);
}}
/>
</div>
)}
</div>
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Apply Settings"
icon={faPenToSquare}
className="mx-auto mt-2"
/>
</div>
);
}
-205
View File
@@ -1,205 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClose } from "@fortawesome/free-solid-svg-icons";
import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global";
import { signOut, useSession } from "next-auth/react";
import { resizeImage } from "@/lib/client/resizeImage";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "../../SubmitButton";
import ProfilePhoto from "../../ProfilePhoto";
import { toast } from "react-hot-toast";
type Props = {
toggleSettingsModal: Function;
setUser: Dispatch<SetStateAction<AccountSettings>>;
user: AccountSettings;
};
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
export default function ProfileSettings({
toggleSettingsModal,
setUser,
user,
}: Props) {
const { update, data } = useSession();
const { account, updateAccount } = useAccountStore();
const [profileStatus, setProfileStatus] = useState(true);
const [submitLoader, setSubmitLoader] = useState(false);
const handleProfileStatus = (e: boolean) => {
setProfileStatus(!e);
};
const handleImageUpload = async (e: any) => {
const file: File = e.target.files[0];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["png", "jpeg", "jpg"];
if (allowedExtensions.includes(fileExtension as string)) {
const resizedFile = await resizeImage(file);
if (
resizedFile.size < 1048576 // 1048576 Bytes == 1MB
) {
const reader = new FileReader();
reader.onload = () => {
setUser({ ...user, profilePic: reader.result as string });
};
reader.readAsDataURL(resizedFile);
} else {
toast.error("Please select a PNG or JPEG file thats less than 1MB.");
}
} else {
toast.error("Invalid file format.");
}
};
useEffect(() => {
setUser({ ...user, newPassword: undefined });
}, []);
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!");
if (user.email !== account.email) {
update({
id: data?.user.id,
});
signOut();
} else if (
user.username !== account.username ||
user.name !== account.name
)
update({
id: data?.user.id,
});
setUser({ ...user, newPassword: undefined });
toggleSettingsModal();
} else toast.error(response.data as string);
setSubmitLoader(false);
};
return (
<div className="flex flex-col gap-3 justify-between sm:w-[35rem] w-80">
<div className="grid sm:grid-cols-2 gap-3 auto-rows-auto">
<div className="sm:row-span-2 sm:justify-self-center mx-auto mb-3">
<p className="text-sm text-sky-700 mb-2 text-center">Profile Photo</p>
<div className="w-28 h-28 flex items-center justify-center rounded-full relative">
<ProfilePhoto
src={user.profilePic}
className="h-auto w-28"
status={handleProfileStatus}
/>
{profileStatus && (
<div
onClick={() =>
setUser({
...user,
profilePic: "",
})
}
className="absolute top-1 left-1 w-5 h-5 flex items-center justify-center border p-1 bg-white border-slate-200 rounded-full text-gray-500 hover:text-red-500 duration-100 cursor-pointer"
>
<FontAwesomeIcon icon={faClose} className="w-3 h-3" />
</div>
)}
<div className="absolute -bottom-3 left-0 right-0 mx-auto w-fit text-center">
<label
htmlFor="upload-photo"
title="PNG or JPG (Max: 3MB)"
className="border border-slate-200 rounded-md bg-white px-2 text-center select-none cursor-pointer text-sky-900 duration-100 hover:border-sky-700"
>
Browse...
<input
type="file"
name="photo"
id="upload-photo"
accept=".png, .jpeg, .jpg"
className="hidden"
onChange={handleImageUpload}
/>
</label>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<div>
<p className="text-sm text-sky-700 mb-2">Display Name</p>
<input
type="text"
value={user.name}
onChange={(e) => setUser({ ...user, name: e.target.value })}
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
<div>
<p className="text-sm text-sky-700 mb-2">Username</p>
<input
type="text"
value={user.username || ""}
onChange={(e) => setUser({ ...user, username: e.target.value })}
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
{emailEnabled ? (
<div>
<p className="text-sm text-sky-700 mb-2">Email</p>
<input
type="text"
value={user.email || ""}
onChange={(e) => setUser({ ...user, email: e.target.value })}
className="w-full rounded-md p-2 border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
) : undefined}
{user.email !== account.email ? (
<p className="text-gray-500">
You will need to log back in after you apply this Email.
</p>
) : undefined}
</div>
</div>
{/* <hr /> TODO: Export functionality
<p className="text-sky-700">Data Settings</p>
<div className="w-fit">
<div className="border border-sky-100 rounded-md bg-white px-2 py-1 text-center select-none cursor-pointer text-sky-900 duration-100 hover:border-sky-700">
Export Data
</div>
</div> */}
<SubmitButton
onClick={submit}
loading={submitLoader}
label="Apply Settings"
icon={faPenToSquare}
className="mx-auto mt-2"
/>
</div>
);
}
-107
View File
@@ -1,107 +0,0 @@
import { Tab } from "@headlessui/react";
import { AccountSettings } from "@/types/global";
import { useState } from "react";
import ChangePassword from "./ChangePassword";
import ProfileSettings from "./ProfileSettings";
import PrivacySettings from "./PrivacySettings";
import BillingPortal from "./BillingPortal";
type Props = {
toggleSettingsModal: Function;
activeUser: AccountSettings;
className?: string;
defaultIndex?: number;
};
const STRIPE_BILLING_PORTAL_URL =
process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL;
export default function UserModal({
className,
defaultIndex,
toggleSettingsModal,
activeUser,
}: Props) {
const [user, setUser] = useState<AccountSettings>(activeUser);
return (
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-sky-700">
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Profile Settings
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Privacy Settings
</Tab>
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Password
</Tab>
{STRIPE_BILLING_PORTAL_URL ? (
<Tab
className={({ selected }) =>
selected
? "px-2 py-1 bg-sky-200 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 rounded-md duration-100 outline-none"
}
>
Billing Portal
</Tab>
) : undefined}
</Tab.List>
<Tab.Panels>
<Tab.Panel>
<ProfileSettings
toggleSettingsModal={toggleSettingsModal}
setUser={setUser}
user={user}
/>
</Tab.Panel>
<Tab.Panel>
<PrivacySettings
toggleSettingsModal={toggleSettingsModal}
setUser={setUser}
user={user}
/>
</Tab.Panel>
<Tab.Panel>
<ChangePassword
togglePasswordFormModal={toggleSettingsModal}
setUser={setUser}
user={user}
/>
</Tab.Panel>
{STRIPE_BILLING_PORTAL_URL ? (
<Tab.Panel>
<BillingPortal />
</Tab.Panel>
) : undefined}
</Tab.Panels>
</Tab.Group>
</div>
);
}
+5 -5
View File
@@ -11,19 +11,19 @@ type Props = {
export default function Modal({ toggleModal, className, children }: Props) {
return (
<div className="overflow-y-auto py-2 fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<div className="overflow-y-auto py-2 fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-30">
<ClickAwayHandler
onClickOutside={toggleModal}
className={`w-fit m-auto mt-10 sm:mt-20 ${className}`}
className={`m-auto ${className || ""}`}
>
<div className="slide-up relative border-sky-100 rounded-2xl border-solid border shadow-lg p-5 bg-white">
<div className="slide-up relative border-sky-100 dark:border-neutral-700 rounded-2xl border-solid border shadow-lg p-5 bg-white dark:bg-neutral-900">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-5 left-5 inline-flex rounded-md cursor-pointer hover:bg-slate-200 duration-100 z-20 p-2"
className="absolute top-5 left-5 inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 z-20 p-2"
>
<FontAwesomeIcon
icon={faChevronLeft}
className="w-4 h-4 text-gray-500"
className="w-4 h-4 text-gray-500 dark:text-gray-300"
/>
</div>
{children}
-14
View File
@@ -2,12 +2,10 @@ import useModalStore from "@/store/modals";
import Modal from "./Modal";
import LinkModal from "./Modal/Link";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import CollectionModal from "./Modal/Collection";
import UserModal from "./Modal/User";
import { useEffect } from "react";
import { useRouter } from "next/router";
@@ -29,8 +27,6 @@ export default function ModalManagement() {
<LinkModal
toggleLinkModal={toggleModal}
method={modal.method}
isOwnerOrMod={modal.isOwnerOrMod as boolean}
defaultIndex={modal.defaultIndex}
activeLink={modal.active as LinkIncludingShortenedCollectionAndTags}
/>
</Modal>
@@ -49,15 +45,5 @@ export default function ModalManagement() {
/>
</Modal>
);
else if (modal && modal.modal === "ACCOUNT")
return (
<Modal toggleModal={toggleModal}>
<UserModal
toggleSettingsModal={toggleModal}
defaultIndex={modal.defaultIndex}
activeUser={modal.active as AccountSettings}
/>
</Modal>
);
else return <></>;
}
+33 -17
View File
@@ -10,6 +10,8 @@ import Search from "@/components/Search";
import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals";
import { useTheme } from "next-themes";
import useWindowDimensions from "@/hooks/useWindowDimensions";
export default function Navbar() {
const { setModal } = useModalStore();
@@ -18,11 +20,25 @@ export default function Navbar() {
const [profileDropdown, setProfileDropdown] = useState(false);
const [sidebar, setSidebar] = useState(false);
const router = useRouter();
window.addEventListener("resize", () => setSidebar(false));
const { theme, setTheme } = useTheme();
const handleToggle = () => {
if (theme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
};
const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);
@@ -33,10 +49,10 @@ export default function Navbar() {
};
return (
<div className="flex justify-between gap-2 items-center px-5 py-2 border-solid border-b-sky-100 border-b h-16">
<div className="flex justify-between gap-2 items-center px-5 py-2 border-solid border-b-sky-100 dark:border-b-neutral-700 border-b h-16">
<div
onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-[0.687rem] text-sky-700 rounded-md duration-100 hover:bg-slate-200"
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-[0.687rem] text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div>
@@ -50,7 +66,7 @@ export default function Navbar() {
method: "CREATE",
});
}}
className="inline-flex gap-1 relative sm:w-[7.2rem] items-center font-semibold select-none cursor-pointer p-[0.687rem] sm:p-2 sm:px-3 rounded-md sm:rounded-full hover:bg-sky-100 text-sky-700 sm:text-white sm:bg-sky-700 sm:hover:bg-sky-600 duration-100 group"
className="inline-flex gap-1 relative sm:w-[7.2rem] items-center font-semibold select-none cursor-pointer p-[0.687rem] sm:p-2 sm:px-3 rounded-md sm:rounded-full hover:bg-sky-100 dark:hover:bg-sky-800 sm:dark:hover:bg-sky-600 text-sky-500 sm:text-white sm:bg-sky-700 sm:hover:bg-sky-600 duration-100 group"
>
<FontAwesomeIcon
icon={faPlus}
@@ -60,20 +76,20 @@ export default function Navbar() {
New Link
</span>
</div>
<div className="relative">
<div
className="flex gap-1 group sm:hover:bg-slate-200 sm:hover:p-1 sm:hover:pr-2 duration-100 h-10 rounded-full items-center w-fit bg-white cursor-pointer"
className="flex gap-1 group sm:hover:bg-slate-200 sm:hover:dark:bg-neutral-700 sm:hover:p-1 sm:hover:pr-2 duration-100 h-10 rounded-full items-center w-fit cursor-pointer"
onClick={() => setProfileDropdown(!profileDropdown)}
id="profile-dropdown"
>
<ProfilePhoto
src={account.profilePic}
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
src={account.image ? account.image : undefined}
priority={true}
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
/>
<p
id="profile-dropdown"
className="font-bold text-sky-700 leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
className="font-bold text-black dark:text-white leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
>
{account.name}
</p>
@@ -83,12 +99,12 @@ export default function Navbar() {
items={[
{
name: "Settings",
href: "/settings/account",
},
{
name: `Switch to ${theme === "light" ? "Dark" : "Light"}`,
onClick: () => {
setModal({
modal: "ACCOUNT",
state: true,
active: account,
});
handleToggle();
setProfileDropdown(!profileDropdown);
},
},
@@ -115,7 +131,7 @@ export default function Navbar() {
onClickOutside={toggleSidebar}
>
<div className="slide-right h-full shadow-lg">
<Sidebar className="" />
<Sidebar />
</div>
</ClickAwayHandler>
</div>
+13 -11
View File
@@ -3,17 +3,19 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import useModalStore from "@/store/modals";
export default function NoLinksFound() {
type Props = {
text?: string;
};
export default function NoLinksFound({ text }: Props) {
const { setModal } = useModalStore();
return (
<div className="border border-solid border-sky-100 w-full p-10 rounded-2xl">
<p className="text-center text-3xl text-sky-700">
You haven&apos;t created any Links Here
<div className="w-full h-full flex flex-col justify-center p-10">
<p className="text-center text-2xl text-black dark:text-white">
{text || "You haven't created any Links Here"}
</p>
<br />
<div className="text-center text-sky-900 text-sm flex items-baseline justify-center gap-1 w-full">
<p>Start by creating a</p>{" "}
<div className="text-center text-black dark:text-white w-full mt-4">
<div
onClick={() => {
setModal({
@@ -22,14 +24,14 @@ export default function NoLinksFound() {
method: "CREATE",
});
}}
className="inline-flex gap-1 relative w-[7.2rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-full text-white bg-sky-700 hover:bg-sky-600 duration-100 group"
className="inline-flex gap-1 relative w-[11.4rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-full dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100 group"
>
<FontAwesomeIcon
icon={faPlus}
className="w-5 h-5 group-hover:ml-9 absolute duration-100"
className="w-5 h-5 group-hover:ml-[4.325rem] absolute duration-100"
/>
<span className="block group-hover:opacity-0 text-right w-full duration-100">
New Link
<span className="group-hover:opacity-0 text-right w-full duration-100">
Create New Link
</span>
</div>
</div>
+21 -24
View File
@@ -2,48 +2,45 @@ import React, { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/free-solid-svg-icons";
import Image from "next/image";
import avatarExists from "@/lib/client/avatarExists";
type Props = {
src: string;
src?: string;
className?: string;
emptyImage?: boolean;
status?: Function;
priority?: boolean;
};
export default function ProfilePhoto({
src,
className,
emptyImage,
status,
}: Props) {
const [error, setError] = useState<boolean>(emptyImage || true);
const checkAvatarExistence = async () => {
const canPass = await avatarExists(src);
setError(!canPass);
};
export default function ProfilePhoto({ src, className, priority }: Props) {
const [image, setImage] = useState("");
useEffect(() => {
if (src) checkAvatarExistence();
if (src && !src?.includes("base64"))
setImage(`/api/v1/${src.replace("uploads/", "").replace(".jpg", "")}`);
else if (!src) setImage("");
else {
setImage(src);
}
}, [src]);
status && status(error || !src);
}, [src, error]);
return error || !src ? (
return !image ? (
<div
className={`bg-sky-500 text-white h-10 w-10 aspect-square shadow rounded-full border border-slate-200 flex items-center justify-center ${className}`}
className={`bg-sky-600 dark:bg-sky-600 text-white h-10 w-10 aspect-square shadow rounded-full border border-slate-200 dark:border-neutral-700 flex items-center justify-center ${
className || ""
}`}
>
<FontAwesomeIcon icon={faUser} className="w-1/2 h-1/2 aspect-square" />
</div>
) : (
<Image
alt=""
src={src}
src={image}
height={112}
width={112}
className={`h-10 w-10 shadow rounded-full aspect-square border border-slate-200 ${className}`}
priority={priority}
draggable={false}
className={`h-10 w-10 bg-sky-600 dark:bg-sky-600 shadow rounded-full aspect-square border border-slate-200 dark:border-neutral-700 ${
className || ""
}`}
/>
);
}
+9 -6
View File
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image";
import { Link as LinkType, Tag } from "@prisma/client";
import isValidUrl from "@/lib/client/isValidUrl";
import unescapeString from "@/lib/client/unescapeString";
interface LinksIncludingTags extends LinkType {
tags: Tag[];
@@ -26,7 +27,7 @@ export default function LinkCard({ link, count }: Props) {
return (
<a href={link.url} target="_blank" rel="noreferrer" className="rounded-3xl">
<div className="bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow-md sm:hover:shadow-none duration-100 rounded-3xl cursor-pointer p-5 flex items-start relative gap-5 sm:gap-10 group/item">
<div className="border border-solid border-sky-100 bg-gradient-to-tr from-slate-200 from-10% to-gray-50 via-20% shadow-md sm:hover:shadow-none duration-100 rounded-3xl cursor-pointer p-5 flex items-start relative gap-5 sm:gap-10 group/item">
{url && (
<>
<Image
@@ -58,19 +59,21 @@ export default function LinkCard({ link, count }: Props) {
<div className="flex justify-between items-center gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between">
<div className="flex items-baseline gap-1">
<p className="text-sm text-sky-500 font-bold">{count + 1}.</p>
<p className="text-lg text-sky-700 font-bold">{link.name}</p>
<p className="text-xs text-gray-500">{count + 1}</p>
<p className="text-lg text-black">
{unescapeString(link.name || link.description)}
</p>
</div>
<p className="text-gray-500 text-sm font-medium">
{link.description}
{unescapeString(link.description)}
</p>
<div className="flex gap-3 items-center flex-wrap my-3">
<div className="flex gap-1 items-center flex-wrap mt-1">
{link.tags.map((e, i) => (
<p
key={i}
className="px-2 py-1 bg-sky-200 text-sky-700 text-xs rounded-3xl cursor-pointer truncate max-w-[10rem]"
className="px-2 py-1 bg-sky-200 text-black text-xs rounded-3xl cursor-pointer truncate max-w-[10rem]"
>
{e.name}
</p>
@@ -79,7 +82,7 @@ export default function LinkCard({ link, count }: Props) {
</div>
<div className="flex gap-2 items-center flex-wrap mt-2">
<p className="text-gray-500">{formattedDate}</p>
<div className="text-sky-500 font-bold flex items-center gap-1">
<div className="text-black flex items-center gap-1">
<p>{url ? url.host : link.url}</p>
</div>
</div>
+5 -3
View File
@@ -20,13 +20,15 @@ export default function RadioButton({ label, state, onClick }: Props) {
/>
<FontAwesomeIcon
icon={faCircleCheck}
className="w-5 h-5 text-sky-700 peer-checked:block hidden"
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:block hidden"
/>
<FontAwesomeIcon
icon={faCircle}
className="w-5 h-5 text-sky-700 peer-checked:hidden block"
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:hidden block"
/>
<span className="text-sky-900 rounded select-none">{label}</span>
<span className="text-black dark:text-white rounded select-none">
{label}
</span>
</label>
);
}
+4 -1
View File
@@ -1,6 +1,9 @@
export default function RequiredBadge() {
return (
<span title="Required Field" className="text-sky-700 cursor-help">
<span
title="Required Field"
className="text-black dark:text-white cursor-help"
>
{" "}
*
</span>
+3 -3
View File
@@ -24,7 +24,7 @@ export default function Search() {
>
<label
htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md p-1 text-sky-500 group-hover:text-sky-700"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md p-1 text-sky-500 dark:text-sky-500"
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="w-5 h-5" />
</label>
@@ -41,10 +41,10 @@ export default function Search() {
}}
onKeyDown={(e) =>
e.key === "Enter" &&
router.push("/search/" + encodeURIComponent(searchQuery))
router.push("/search?q=" + encodeURIComponent(searchQuery))
}
autoFocus={searchBox}
className="border border-sky-100 rounded-md pl-10 py-2 pr-2 w-44 sm:w-60 focus:border-sky-700 md:focus:w-80 hover:border-sky-700 duration-100 outline-none"
className="border border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-10 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800"
/>
</div>
);
+210
View File
@@ -0,0 +1,210 @@
import useCollectionStore from "@/store/collections";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faUser,
faPalette,
faBoxArchive,
faKey,
} from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import {
faCircleQuestion,
faCreditCard,
} from "@fortawesome/free-regular-svg-icons";
import {
faGithub,
faMastodon,
faXTwitter,
} from "@fortawesome/free-brands-svg-icons";
export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.2.0";
const { collections } = useCollectionStore();
const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => {
setActive(router.asPath);
}, [router, collections]);
return (
<div
className={`dark:bg-neutral-900 bg-white h-full w-64 overflow-y-auto border-solid border-white border dark:border-neutral-900 border-r-sky-100 dark:border-r-neutral-700 p-5 z-20 flex flex-col gap-5 justify-between ${
className || ""
}`}
>
<div className="flex flex-col gap-1">
<Link href="/settings/account">
<div
className={`${
active === `/settings/account`
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faUser}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
Account
</p>
</div>
</Link>
<Link href="/settings/appearance">
<div
className={`${
active === `/settings/appearance`
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faPalette}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
Appearance
</p>
</div>
</Link>
<Link href="/settings/archive">
<div
className={`${
active === `/settings/archive`
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faBoxArchive}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
Archive
</p>
</div>
</Link>
<Link href="/settings/password">
<div
className={`${
active === `/settings/password`
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faKey}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
Password
</p>
</div>
</Link>
{process.env.NEXT_PUBLIC_STRIPE ? (
<Link href="/settings/billing">
<div
className={`${
active === `/settings/billing`
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faCreditCard}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
Billing
</p>
</div>
</Link>
) : undefined}
</div>
<div className="flex flex-col gap-1">
<Link
href={`https://github.com/linkwarden/linkwarden/releases/tag/${LINKWARDEN_VERSION}`}
target="_blank"
className="dark:text-gray-300 text-gray-500 text-sm ml-2 hover:opacity-50 duration-100"
>
Linkwarden {LINKWARDEN_VERSION}
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faCircleQuestion as any}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
Help
</p>
</div>
</Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faGithub as any}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
GitHub
</p>
</div>
</Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faXTwitter as any}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
Twitter
</p>
</div>
</Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faMastodon as any}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
Mastodon
</p>
</div>
</Link>
</div>
</div>
);
}
+85 -58
View File
@@ -6,13 +6,14 @@ import {
faChartSimple,
faChevronDown,
faLink,
faGlobe,
faThumbTack,
} from "@fortawesome/free-solid-svg-icons";
import useTagStore from "@/store/tags";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Disclosure, Transition } from "@headlessui/react";
import Image from "next/image";
export default function Sidebar({ className }: { className?: string }) {
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
@@ -51,54 +52,73 @@ export default function Sidebar({ className }: { className?: string }) {
return (
<div
className={`bg-gray-100 h-full w-64 xl:w-80 overflow-y-auto border-solid border-r-sky-100 px-2 border z-20 ${className}`}
className={`bg-gray-100 dark:bg-neutral-800 h-full w-64 xl:w-80 overflow-y-auto border-solid border dark:border-neutral-800 border-r-sky-100 dark:border-r-neutral-700 px-2 z-20 ${
className || ""
}`}
>
<div className="flex justify-center gap-2 mt-2">
<Link
href="/dashboard"
className={`${
active === "/dashboard"
? "bg-sky-200"
: "hover:bg-slate-200 bg-gray-100"
} outline-sky-100 outline-1 duration-100 py-1 px-2 rounded-md cursor-pointer flex justify-center flex-col items-center gap-1 w-full`}
>
<FontAwesomeIcon
icon={faChartSimple}
className={`w-8 h-8 drop-shadow text-sky-500`}
/>
<p className="text-sky-700 text-xs font-semibold">Dashboard</p>
<div className="flex flex-col gap-2 mt-2">
<Link href={`/dashboard`}>
<div
className={`${
active === `/dashboard` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faChartSimple}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full">
Dashboard
</p>
</div>
</Link>
<Link
href="/links"
className={`${
active === "/links"
? "bg-sky-200"
: "hover:bg-slate-200 bg-gray-100"
} outline-sky-100 outline-1 duration-100 py-1 px-2 rounded-md cursor-pointer flex justify-center flex-col items-center gap-1 w-full`}
>
<FontAwesomeIcon
icon={faLink}
className={`w-8 h-8 drop-shadow text-sky-500`}
/>
<p className="text-sky-700 text-xs font-semibold">
<span className="hidden xl:inline-block">All</span> Links
</p>
<Link href={`/links`}>
<div
className={`${
active === `/links` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faLink}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full">
All Links
</p>
</div>
</Link>
<Link
href="/collections"
className={`${
active === "/collections" ? "bg-sky-200" : "hover:bg-slate-200"
} outline-sky-100 outline-1 duration-100 py-1 px-2 rounded-md cursor-pointer flex justify-center flex-col items-center gap-1 w-full`}
>
<FontAwesomeIcon
icon={faFolder}
className={`w-8 h-8 drop-shadow text-sky-500`}
/>
<p className="text-sky-700 text-xs font-semibold">
<span className="hidden xl:inline-block">All</span> Collections
</p>
<Link href={`/collections`}>
<div
className={`${
active === `/collections` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faFolder}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full">
All Collections
</p>
</div>
</Link>
<Link href={`/links/pinned`}>
<div
className={`${
active === `/links/pinned` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faThumbTack}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full">
Pinned Links
</p>
</div>
</Link>
</div>
@@ -107,7 +127,7 @@ export default function Sidebar({ className }: { className?: string }) {
onClick={() => {
setCollectionDisclosure(!collectionDisclosure);
}}
className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-gray-500 mt-5"
className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-gray-500 dark:text-gray-300 mt-5"
>
<p>Collections</p>
@@ -136,19 +156,26 @@ export default function Sidebar({ className }: { className?: string }) {
<div
className={`${
active === `/collections/${e.id}`
? "bg-sky-200"
: "hover:bg-slate-200 bg-gray-100"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-1 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faFolder}
className="w-6 h-6 drop-shadow"
style={{ color: e.color }}
/>
<p className="text-sky-700 truncate w-full pr-7">
<p className="text-black dark:text-white truncate w-full">
{e.name}
</p>
{e.isPublic ? (
<FontAwesomeIcon
icon={faGlobe}
title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
</div>
</Link>
);
@@ -157,7 +184,7 @@ export default function Sidebar({ className }: { className?: string }) {
<div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-gray-500 text-xs font-semibold truncate w-full pr-7">
<p className="text-gray-500 dark:text-gray-300 text-xs font-semibold truncate w-full pr-7">
You Have No Collections...
</p>
</div>
@@ -170,7 +197,7 @@ export default function Sidebar({ className }: { className?: string }) {
onClick={() => {
setTagDisclosure(!tagDisclosure);
}}
className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-gray-500 mt-5"
className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-gray-500 dark:text-gray-300 mt-5"
>
<p>Tags</p>
<FontAwesomeIcon
@@ -196,16 +223,16 @@ export default function Sidebar({ className }: { className?: string }) {
<div
className={`${
active === `/tags/${e.id}`
? "bg-sky-200"
: "hover:bg-slate-200 bg-gray-100"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-1 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faHashtag}
className="w-4 h-4 text-sky-500 mt-1"
className="w-4 h-4 text-sky-500 dark:text-sky-500 mt-1"
/>
<p className="text-sky-700 truncate w-full pr-7">
<p className="text-black dark:text-white truncate w-full pr-7">
{e.name}
</p>
</div>
@@ -216,7 +243,7 @@ export default function Sidebar({ className }: { className?: string }) {
<div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-gray-500 text-xs font-semibold truncate w-full pr-7">
<p className="text-gray-500 dark:text-gray-300 text-xs font-semibold truncate w-full pr-7">
You Have No Tags...
</p>
</div>
+4 -2
View File
@@ -21,9 +21,11 @@ export default function SortDropdown({
const target = e.target as HTMLInputElement;
if (target.id !== "sort-dropdown") toggleSortDropdown();
}}
className="absolute top-8 right-0 border border-sky-100 shadow-md bg-gray-50 rounded-md p-2 z-20 w-48"
className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-52"
>
<p className="mb-2 text-sky-900 text-center font-semibold">Sort by</p>
<p className="mb-2 text-black dark:text-white text-center font-semibold">
Sort by
</p>
<div className="flex flex-col gap-2">
<RadioButton
label="Date (Newest First)"
+8 -5
View File
@@ -2,11 +2,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
type Props = {
onClick: Function;
onClick?: Function;
icon?: IconProp;
label: string;
loading: boolean;
className?: string;
type?: "button" | "submit" | "reset" | undefined;
};
export default function SubmitButton({
@@ -15,20 +16,22 @@ export default function SubmitButton({
label,
loading,
className,
type,
}: Props) {
return (
<div
<button
type={type ? type : undefined}
className={`text-white flex items-center gap-2 py-2 px-5 rounded-md text-lg tracking-wide select-none font-semibold duration-100 w-fit ${
loading
? "bg-sky-600 cursor-auto"
: "bg-sky-700 hover:bg-sky-600 cursor-pointer"
} ${className}`}
} ${className || ""}`}
onClick={() => {
if (!loading) onClick();
if (!loading && onClick) onClick();
}}
>
{icon && <FontAwesomeIcon icon={icon} className="h-5" />}
<p className="text-center w-full">{label}</p>
</div>
</button>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { ChangeEventHandler, KeyboardEventHandler } from "react";
type Props = {
autoFocus?: boolean;
value?: string;
type?: string;
placeholder?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
className?: string;
};
export default function TextInput({
autoFocus,
value,
type,
placeholder,
onChange,
onKeyDown,
className,
}: Props) {
return (
<input
autoFocus={autoFocus}
type={type ? type : "text"}
placeholder={placeholder}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
className={`w-full rounded-md p-2 border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-950 border-solid border outline-none focus:border-sky-300 focus:dark:border-sky-600 duration-100 ${
className || ""
}`}
/>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useTheme } from "next-themes";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
export default function ToggleDarkMode() {
const { theme, setTheme } = useTheme();
const handleToggle = () => {
if (theme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
};
return (
<div
className="flex gap-1 duration-100 h-10 rounded-full items-center w-fit cursor-pointer"
onClick={handleToggle}
>
<div className="shadow bg-sky-700 dark:bg-sky-400 flex items-center justify-center rounded-full text-white w-10 h-10 duration-100">
<FontAwesomeIcon
icon={theme === "dark" ? faSun : faMoon}
className="w-1/2 h-1/2"
/>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
version: "3.5"
services:
postgres:
image: postgres
env_file: .env
restart: always
volumes:
- ./pgdata:/var/lib/postgresql/data
linkwarden:
env_file: .env
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
restart: always
image: ghcr.io/linkwarden/linkwarden:latest
ports:
- 3000:3000
volumes:
- ./data:/data/data
depends_on:
- postgres
+9 -8
View File
@@ -5,21 +5,22 @@ const useDetectPageBottom = () => {
useEffect(() => {
const handleScroll = () => {
const offsetHeight = document.documentElement.offsetHeight;
const innerHeight = window.innerHeight;
const scrollTop = document.documentElement.scrollTop;
const totalHeight = document.documentElement.scrollHeight;
const scrolledHeight = window.scrollY + window.innerHeight;
const hasReachedBottom = offsetHeight - (innerHeight + scrollTop) <= 100;
setReachedBottom(hasReachedBottom);
if (scrolledHeight >= totalHeight) {
setReachedBottom(true);
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return reachedBottom;
return { reachedBottom, setReachedBottom };
};
export default useDetectPageBottom;
+10 -4
View File
@@ -2,7 +2,6 @@ import useCollectionStore from "@/store/collections";
import { useEffect } from "react";
import { useSession } from "next-auth/react";
import useTagStore from "@/store/tags";
import useLinkStore from "@/store/links";
import useAccountStore from "@/store/account";
export default function useInitialData() {
@@ -10,14 +9,21 @@ export default function useInitialData() {
const { setCollections } = useCollectionStore();
const { setTags } = useTagStore();
// const { setLinks } = useLinkStore();
const { setAccount } = useAccountStore();
const { account, setAccount } = useAccountStore();
// Get account info
useEffect(() => {
if (status === "authenticated") {
setAccount(data?.user.id as number);
}
}, [status, data]);
// Get the rest of the data
useEffect(() => {
if (account.id && (!process.env.NEXT_PUBLIC_STRIPE || account.username)) {
setCollections();
setTags();
// setLinks();
setAccount(data.user.id);
}
}, [status]);
}, [account]);
}
+48 -15
View File
@@ -7,33 +7,55 @@ import useLinkStore from "@/store/links";
export default function useLinks(
{
sort,
searchFilter,
searchQuery,
pinnedOnly,
collectionId,
tagId,
}: Omit<LinkRequestQuery, "cursor"> = { sort: 0 }
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
}: LinkRequestQuery = { sort: 0 }
) {
const { links, setLinks, resetLinks } = useLinkStore();
const router = useRouter();
const hasReachedBottom = useDetectPageBottom();
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
const requestBody: LinkRequestQuery = {
cursor,
const params = {
sort,
searchFilter,
searchQuery,
pinnedOnly,
cursor,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
};
const encodedData = encodeURIComponent(JSON.stringify(requestBody));
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
const queryString = buildQueryString(params);
const response = await fetch(
`/api/routes/links?body=${encodeURIComponent(encodedData)}`
`/api/v1/${
router.asPath === "/dashboard" ? "dashboard" : "links"
}?${queryString}`
);
const data = await response.json();
@@ -45,9 +67,20 @@ export default function useLinks(
resetLinks();
getLinks(true);
}, [router, sort, searchFilter]);
}, [
router,
sort,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTextContent,
searchByTags,
]);
useEffect(() => {
if (hasReachedBottom) getLinks(false, links?.at(-1)?.id);
}, [hasReachedBottom]);
if (reachedBottom) getLinks(false, links?.at(-1)?.id);
setReachedBottom(false);
}, [reachedBottom]);
}
-30
View File
@@ -1,30 +0,0 @@
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
export default function useRedirect() {
const router = useRouter();
const { status } = useSession();
const [redirect, setRedirect] = useState(true);
useEffect(() => {
if (
status === "authenticated" &&
(router.pathname === "/login" || router.pathname === "/register")
) {
router.push("/").then(() => {
setRedirect(false);
});
} else if (
status === "unauthenticated" &&
!(router.pathname === "/login" || router.pathname === "/register")
) {
router.push("/login").then(() => {
setRedirect(false);
});
} else if (status === "loading") setRedirect(true);
else setRedirect(false);
}, [status]);
return redirect;
}
+25
View File
@@ -0,0 +1,25 @@
import React, { useState, useEffect } from "react";
export default function useWindowDimensions() {
const [dimensions, setDimensions] = useState({
height: window.innerHeight,
width: window.innerWidth,
});
useEffect(() => {
function handleResize() {
setDimensions({
height: window.innerHeight,
width: window.innerWidth,
});
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return dimensions;
}
+24 -14
View File
@@ -4,6 +4,7 @@ import Loader from "../components/Loader";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useInitialData from "@/hooks/useInitialData";
import useAccountStore from "@/store/account";
interface Props {
children: ReactNode;
@@ -13,40 +14,49 @@ export default function AuthRedirect({ children }: Props) {
const router = useRouter();
const { status, data } = useSession();
const [redirect, setRedirect] = useState(true);
const { account } = useAccountStore();
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
useInitialData();
useEffect(() => {
if (!router.pathname.startsWith("/public")) {
if (
status === "authenticated" &&
account.id &&
!account.subscription?.active &&
stripeEnabled
) {
router.push("/subscribe").then(() => {
setRedirect(false);
});
}
// Redirect to "/choose-username" if user is authenticated and is either a subscriber OR subscription is undefiend, and doesn't have a username
else if (
emailEnabled &&
status === "authenticated" &&
(data.user.isSubscriber === true ||
data.user.isSubscriber === undefined) &&
!data.user.username
account.subscription?.active &&
stripeEnabled &&
account.id &&
!account.username
) {
router.push("/choose-username").then(() => {
setRedirect(false);
});
} else if (
status === "authenticated" &&
data.user.isSubscriber === false
) {
router.push("/subscribe").then(() => {
setRedirect(false);
});
} else if (
status === "authenticated" &&
account.id &&
(router.pathname === "/login" ||
router.pathname === "/register" ||
router.pathname === "/confirmation" ||
router.pathname === "/subscribe" ||
router.pathname === "/choose-username" ||
router.pathname === "/forgot")
router.pathname === "/forgot" ||
router.pathname === "/")
) {
router.push("/").then(() => {
router.push("/dashboard").then(() => {
setRedirect(false);
});
} else if (
@@ -66,7 +76,7 @@ export default function AuthRedirect({ children }: Props) {
} else {
setRedirect(false);
}
}, [status]);
}, [status, account, router.pathname]);
if (status !== "loading" && !redirect) return <>{children}</>;
else return <></>;
+59
View File
@@ -0,0 +1,59 @@
import { useTheme } from "next-themes";
import Image from "next/image";
import Link from "next/link";
import React, { ReactNode } from "react";
interface Props {
text?: string;
children: ReactNode;
}
export default function CenteredForm({ text, children }: Props) {
const { theme } = useTheme();
return (
<div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5">
<div className="m-auto flex flex-col gap-2 w-full">
{theme ? (
<Image
src={`/linkwarden_${theme === "dark" ? "dark" : "light"}.png`}
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
) : undefined}
{/* {theme === "dark" ? (
<Image
src="/linkwarden_dark.png"
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
) : (
<Image
src="/linkwarden_light.png"
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
)} */}
{text ? (
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold text-black dark:text-white px-2 text-center">
{text}
</p>
) : undefined}
{children}
<p className="text-center text-xs text-gray-500 mb-5 dark:text-gray-400">
© {new Date().getFullYear()}{" "}
<Link href="https://linkwarden.app" className="font-semibold">
Linkwarden
</Link>
. All rights reserved.
</p>
</div>
</div>
);
}
+195
View File
@@ -0,0 +1,195 @@
import LinkSidebar from "@/components/LinkSidebar";
import { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
import { useRouter } from "next/router";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import {
faPen,
faBoxesStacked,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import useLinkStore from "@/store/links";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
interface Props {
children: ReactNode;
}
export default function LinkLayout({ children }: Props) {
const { modal } = useModalStore();
const router = useRouter();
useEffect(() => {
modal
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "auto");
}, [modal]);
const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => {
setSidebar(!sidebar);
};
const session = useSession();
const userId = session.data?.user.id;
const { setModal } = useModalStore();
const { links, removeLink } = useLinkStore();
const { collections } = useCollectionStore();
const [linkCollection, setLinkCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection.id));
}, [link]);
return (
<>
<ModalManagement />
<div className="flex mx-auto">
<div className="hidden lg:block fixed left-5 h-screen">
<LinkSidebar />
</div>
<div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5">
<div className="flex gap-3 mb-5 duration-100 items-center justify-between">
{/* <div
onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div> */}
<div
onClick={() => router.push(`/collections/${linkCollection?.id}`)}
className="inline-flex gap-1 hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back{" "}
<span className="hidden sm:inline-block">
to <span className="capitalize">{linkCollection?.name}</span>
</span>
</div>
<div className="lg:hidden">
<div className="flex gap-5">
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
<div
title="Edit"
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "UPDATE",
})
: undefined;
}}
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
) : undefined}
<div
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined;
}}
title="Preserved Formats"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
<div
onClick={() => {
if (link?.id) {
removeLink(link.id);
router.back();
}
}}
title="Delete"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
) : undefined}
</div>
</div>
</div>
{children}
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
onClickOutside={toggleSidebar}
>
<div className="slide-right h-full shadow-lg">
<LinkSidebar onClick={() => setSidebar(false)} />
</div>
</ClickAwayHandler>
</div>
) : null}
</div>
</div>
</>
);
}
+49 -27
View File
@@ -1,23 +1,16 @@
import Navbar from "@/components/Navbar";
import AnnouncementBar from "@/components/AnnouncementBar";
import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect } from "react";
import { useSession } from "next-auth/react";
import Loader from "../components/Loader";
import useRedirect from "@/hooks/useRedirect";
import { useRouter } from "next/router";
import { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
import getLatestVersion from "@/lib/client/getLatestVersion";
interface Props {
children: ReactNode;
}
export default function MainLayout({ children }: Props) {
const { status, data } = useSession();
const router = useRouter();
const redirect = useRedirect();
const routeExists = router.route === "/_error" ? false : true;
const { modal } = useModalStore();
useEffect(() => {
@@ -26,24 +19,53 @@ export default function MainLayout({ children }: Props) {
: (document.body.style.overflow = "auto");
}, [modal]);
if (status === "authenticated" && !redirect && routeExists)
return (
<>
<ModalManagement />
const showAnnouncementBar = localStorage.getItem("showAnnouncementBar");
const [showAnnouncement, setShowAnnouncement] = useState(
showAnnouncementBar ? showAnnouncementBar === "true" : true
);
<div className="flex">
<div className="hidden lg:block">
<Sidebar className="fixed top-0" />
</div>
useEffect(() => {
getLatestVersion(setShowAnnouncement);
}, []);
<div className="w-full lg:ml-64 xl:ml-80">
<Navbar />
{children}
</div>
useEffect(() => {
if (showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "true");
setShowAnnouncement(true);
} else if (!showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "false");
setShowAnnouncement(false);
}
}, [showAnnouncement]);
const toggleAnnouncementBar = () => {
setShowAnnouncement(!showAnnouncement);
};
return (
<>
<ModalManagement />
{showAnnouncement ? (
<AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} />
) : undefined}
<div className="flex">
<div className="hidden lg:block">
<Sidebar
className={`fixed ${showAnnouncement ? "top-10" : "top-0"}`}
/>
</div>
</>
);
else if ((status === "unauthenticated" && !redirect) || !routeExists)
return <>{children}</>;
else return <></>;
<div
className={`w-full flex flex-col min-h-${
showAnnouncement ? "full" : "screen"
} lg:ml-64 xl:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
>
<Navbar />
{children}
</div>
</div>
</>
);
}
+93
View File
@@ -0,0 +1,93 @@
import SettingsSidebar from "@/components/SettingsSidebar";
import { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
import { useRouter } from "next/router";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions";
interface Props {
children: ReactNode;
}
export default function SettingsLayout({ children }: Props) {
const { modal } = useModalStore();
const router = useRouter();
useEffect(() => {
modal
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "auto");
}, [modal]);
const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => {
setSidebar(!sidebar);
};
return (
<>
<ModalManagement />
<div className="flex max-w-screen-md mx-auto">
<div className="hidden lg:block fixed h-screen">
<SettingsSidebar />
</div>
<div className="w-full flex flex-col min-h-screen p-5 lg:ml-64">
<div className="flex gap-3">
<div
onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div>
<Link
href="/dashboard"
className="inline-flex gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5" />
</Link>
<p className="capitalize text-3xl font-thin">
{router.asPath.split("/").pop()} Settings
</p>
</div>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
{children}
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
onClickOutside={toggleSidebar}
>
<div className="slide-right h-full shadow-lg">
<SettingsSidebar />
</div>
</ClickAwayHandler>
</div>
) : null}
</div>
</div>
</>
);
}
+130 -58
View File
@@ -1,79 +1,151 @@
import { Page, chromium, devices } from "playwright";
import { chromium, devices } from "playwright";
import { prisma } from "@/lib/api/db";
import createFile from "@/lib/api/storage/createFile";
import sendToWayback from "./sendToWayback";
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";
export default async function archive(
linkId: number,
url: string,
collectionId: number,
linkId: number
userId: number
) {
const browser = await chromium.launch();
const context = await browser.newContext(devices["Desktop Chrome"]);
const page = await context.newPage();
const user = await prisma.user.findUnique({ where: { id: userId } });
try {
await page.goto(url, { waitUntil: "domcontentloaded" });
const targetLink = await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: user?.archiveAsScreenshot ? "pending" : null,
pdfPath: user?.archiveAsPDF ? "pending" : null,
readabilityPath: "pending",
lastPreserved: new Date().toISOString(),
},
});
await autoScroll(page);
// Archive.org
const linkExists = await prisma.link.findUnique({
where: {
id: linkId,
},
});
if (user?.archiveAsWaybackMachine) sendToWayback(url);
if (linkExists) {
const pdf = await page.pdf({
width: "1366px",
height: "1931px",
printBackground: true,
margin: { top: "15px", bottom: "15px" },
});
const screenshot = await page.screenshot({
fullPage: true,
if (user?.archiveAsPDF || user?.archiveAsScreenshot) {
const browser = await chromium.launch();
const context = await browser.newContext(devices["Desktop Chrome"]);
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: "domcontentloaded" });
const content = await page.content();
// Readability
const window = new JSDOM("").window;
const purify = DOMPurify(window);
const cleanedUpContent = purify.sanitize(content);
const dom = new JSDOM(cleanedUpContent, { url: url });
const article = new Readability(dom.window.document).parse();
const articleText = article?.textContent
.replace(/ +(?= )/g, "") // strip out multiple spaces
.replace(/(\r\n|\n|\r)/gm, " "); // strip out line breaks
await createFile({
data: JSON.stringify(article),
filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
});
createFile({
data: screenshot,
filePath: `archives/${collectionId}/${linkId}.png`,
await prisma.link.update({
where: { id: linkId },
data: {
readabilityPath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
textContent: articleText,
},
});
createFile({
data: pdf,
filePath: `archives/${collectionId}/${linkId}.pdf`,
// Screenshot/PDF
let faulty = false;
await page
.evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30)
.catch((e) => (faulty = true));
const linkExists = await prisma.link.findUnique({
where: { id: linkId },
});
if (linkExists && !faulty) {
if (user.archiveAsScreenshot) {
const screenshot = await page.screenshot({ fullPage: true });
await createFile({
data: screenshot,
filePath: `archives/${linkExists.collectionId}/${linkId}.png`,
});
}
if (user.archiveAsPDF) {
const pdf = await page.pdf({
width: "1366px",
height: "1931px",
printBackground: true,
margin: { top: "15px", bottom: "15px" },
});
await createFile({
data: pdf,
filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`,
});
}
await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: user.archiveAsScreenshot
? `archives/${linkExists.collectionId}/${linkId}.png`
: null,
pdfPath: user.archiveAsPDF
? `archives/${linkExists.collectionId}/${linkId}.pdf`
: null,
},
});
} else if (faulty) {
await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: null,
pdfPath: null,
},
});
}
} catch (err) {
console.log(err);
throw err;
} finally {
await browser.close();
}
await browser.close();
} catch (err) {
console.log(err);
await browser.close();
}
}
const autoScroll = async (page: Page) => {
await page.evaluate(async () => {
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => {
reject(new Error("Auto scroll took too long (more than 20 seconds)."));
}, 20000);
});
const scrollingPromise = new Promise<void>((resolve) => {
let totalHeight = 0;
let distance = 100;
let scrollDown = setInterval(() => {
let scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(scrollDown);
window.scroll(0, 0);
resolve();
}
}, 100);
});
await Promise.race([scrollingPromise, timeoutPromise]);
const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => {
reject(new Error(`Webpage was too long to be archived.`));
}, AUTOSCROLL_TIMEOUT * 1000);
});
const scrollingPromise = new Promise<void>((resolve) => {
let totalHeight = 0;
let distance = 100;
let scrollDown = setInterval(() => {
let scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(scrollDown);
window.scroll(0, 0);
resolve();
}
}, 100);
});
await Promise.race([scrollingPromise, timeoutPromise]);
};
-54
View File
@@ -1,54 +0,0 @@
import Stripe from "stripe";
export default async function checkSubscription(
stripeSecretKey: string,
email: string,
priceId: string
) {
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2022-11-15",
});
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
});
let subscriptionCanceledAt: number | null | undefined;
const isSubscriber = listByEmail.data.some((customer, i) => {
const hasValidSubscription = customer.subscriptions?.data.some(
(subscription) => {
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400
: 1209600;
subscriptionCanceledAt = subscription.canceled_at;
const isNotCanceledOrHasTime = !(
subscription.canceled_at &&
new Date() >
new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000)
);
return (
subscription?.items?.data?.some(
(subscriptionItem) => subscriptionItem?.plan?.id === priceId
) && isNotCanceledOrHasTime
);
}
);
return (
customer.email?.toLowerCase() === email.toLowerCase() &&
hasValidSubscription
);
});
return {
isSubscriber,
subscriptionCanceledAt,
};
}
+52
View File
@@ -0,0 +1,52 @@
import Stripe from "stripe";
const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID;
const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID;
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function checkSubscriptionByEmail(email: string) {
let active: boolean | undefined,
stripeSubscriptionId: string | undefined,
currentPeriodStart: number | undefined,
currentPeriodEnd: number | undefined;
if (!STRIPE_SECRET_KEY)
return {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
};
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
console.log("Request made to Stripe by:", email);
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
});
listByEmail.data.some((customer) => {
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)
);
stripeSubscriptionId = subscription.id;
currentPeriodStart = subscription.current_period_start * 1000;
currentPeriodEnd = subscription.current_period_end * 1000;
});
});
return {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
};
}
@@ -4,15 +4,16 @@ import { Collection, UsersAndCollections } from "@prisma/client";
import removeFolder from "@/lib/api/storage/removeFolder";
export default async function deleteCollection(
collection: { id: number },
userId: number
userId: number,
collectionId: number
) {
const collectionId = collection.id;
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const collectionIsAccessible = (await getPermission(userId, collectionId)) as
const collectionIsAccessible = (await getPermission({
userId,
collectionId,
})) as
| (Collection & {
members: UsersAndCollections[];
})
@@ -4,16 +4,17 @@ import getPermission from "@/lib/api/getPermission";
import { Collection, UsersAndCollections } from "@prisma/client";
export default async function updateCollection(
collection: CollectionIncludingMembersAndLinkCount,
userId: number
userId: number,
collectionId: number,
data: CollectionIncludingMembersAndLinkCount
) {
if (!collection.id)
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const collectionIsAccessible = (await getPermission(
const collectionIsAccessible = (await getPermission({
userId,
collection.id
)) as
collectionId,
})) as
| (Collection & {
members: UsersAndCollections[];
})
@@ -26,23 +27,23 @@ export default async function updateCollection(
await prisma.usersAndCollections.deleteMany({
where: {
collection: {
id: collection.id,
id: collectionId,
},
},
});
return await prisma.collection.update({
where: {
id: collection.id,
id: collectionId,
},
data: {
name: collection.name.trim(),
description: collection.description,
color: collection.color,
isPublic: collection.isPublic,
name: data.name.trim(),
description: data.description,
color: data.color,
isPublic: data.isPublic,
members: {
create: collection.members.map((e) => ({
create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } },
canCreate: e.canCreate,
canUpdate: e.canUpdate,
@@ -58,6 +59,7 @@ export default async function updateCollection(
include: {
user: {
select: {
image: true,
username: true,
name: true,
id: true,
@@ -18,6 +18,7 @@ export default async function getCollection(userId: number) {
select: {
username: true,
name: true,
image: true,
},
},
},
@@ -40,14 +40,6 @@ export default async function postCollection(
name: collection.name.trim(),
description: collection.description,
color: collection.color,
members: {
create: collection.members.map((e) => ({
user: { connect: { id: e.user.id } },
canCreate: e.canCreate,
canUpdate: e.canUpdate,
canDelete: e.canDelete,
})),
},
},
include: {
_count: {
@@ -0,0 +1,78 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getDashboardData(
userId: number,
query: LinkRequestQuery
) {
let order: any;
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
else if (query.sort === Sort.NameZA) order = { name: "desc" };
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
const pinnedLinks = await prisma.link.findMany({
take: 6,
where: {
AND: [
{
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
{
pinnedBy: { some: { id: userId } },
},
],
},
include: {
tags: true,
collection: true,
pinnedBy: {
where: { id: userId },
select: { id: true },
},
},
orderBy: order || { createdAt: "desc" },
});
const recentlyAddedLinks = await prisma.link.findMany({
take: 6,
where: {
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
include: {
tags: true,
collection: true,
pinnedBy: {
where: { id: userId },
select: { id: true },
},
},
orderBy: order || { createdAt: "desc" },
});
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
(a, b) => (new Date(b.createdAt) as any) - (new Date(a.createdAt) as any)
);
return { response: links, status: 200 };
}
+121 -119
View File
@@ -1,135 +1,139 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(userId: number, body: string) {
const query: LinkRequestQuery = JSON.parse(decodeURIComponent(body));
console.log(query);
export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
// Sorting logic
let order: any;
if (query.sort === Sort.DateNewestFirst)
order = {
createdAt: "desc",
};
else if (query.sort === Sort.DateOldestFirst)
order = {
createdAt: "asc",
};
else if (query.sort === Sort.NameAZ)
order = {
name: "asc",
};
else if (query.sort === Sort.NameZA)
order = {
name: "desc",
};
else if (query.sort === Sort.DescriptionAZ)
order = {
name: "asc",
};
else if (query.sort === Sort.DescriptionZA)
order = {
name: "desc",
};
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
else if (query.sort === Sort.NameZA) order = { name: "desc" };
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
const searchConditions = [];
if (query.searchQueryString) {
if (query.searchByName) {
searchConditions.push({
name: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByUrl) {
searchConditions.push({
url: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByDescription) {
searchConditions.push({
description: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByTextContent) {
searchConditions.push({
textContent: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByTags) {
searchConditions.push({
tags: {
some: {
name: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
OR: [
{ ownerId: userId },
{
links: {
some: {
collection: {
members: {
some: { userId },
},
},
},
},
},
],
},
},
});
}
}
const tagCondition = [];
if (query.tagId) {
tagCondition.push({
tags: {
some: {
id: query.tagId,
},
},
});
}
const collectionCondition = [];
if (query.collectionId) {
collectionCondition.push({
collection: {
id: query.collectionId,
},
});
}
const links = await prisma.link.findMany({
take: Number(process.env.PAGINATION_TAKE_COUNT) || 20,
skip: query.cursor ? 1 : undefined,
cursor: query.cursor
? {
id: query.cursor,
}
: undefined,
cursor: query.cursor ? { id: query.cursor } : undefined,
where: {
collection: {
id: query.collectionId ? query.collectionId : undefined, // If collectionId was defined, filter by collection
OR: [
{
ownerId: userId,
},
{
members: {
some: {
userId,
AND: [
{
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
},
},
],
},
[query.searchQuery ? "OR" : "AND"]: [
{
pinnedBy: query.pinnedOnly ? { some: { id: userId } } : undefined,
},
{
name: {
contains:
query.searchQuery && query.searchFilter?.name
? query.searchQuery
: undefined,
mode: "insensitive",
],
},
},
...collectionCondition,
{
url: {
contains:
query.searchQuery && query.searchFilter?.url
? query.searchQuery
: undefined,
mode: "insensitive",
},
},
{
description: {
contains:
query.searchQuery && query.searchFilter?.description
? query.searchQuery
: undefined,
mode: "insensitive",
},
},
{
tags:
query.searchQuery && !query.searchFilter?.tags
? undefined
: {
some: query.tagId
? {
// If tagId was defined, filter by tag
id: query.tagId,
name:
query.searchQuery && query.searchFilter?.tags
? {
contains: query.searchQuery,
mode: "insensitive",
}
: undefined,
OR: [
{ ownerId: userId }, // Tags owned by the user
{
links: {
some: {
name: {
contains:
query.searchQuery &&
query.searchFilter?.tags
? query.searchQuery
: undefined,
mode: "insensitive",
},
collection: {
members: {
some: {
userId, // Tags from collections where the user is a member
},
},
},
},
},
},
],
}
OR: [
...tagCondition,
{
[query.searchQueryString ? "OR" : "AND"]: [
{
pinnedBy: query.pinnedOnly
? { some: { id: userId } }
: undefined,
},
...searchConditions,
],
},
],
},
],
},
@@ -141,9 +145,7 @@ export default async function getLink(userId: number, body: string) {
select: { id: true },
},
},
orderBy: order || {
createdAt: "desc",
},
orderBy: order || { createdAt: "desc" },
});
return { response: links, status: 200 };
@@ -1,20 +1,12 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile";
export default async function deleteLink(
link: LinkIncludingShortenedCollectionAndTags,
userId: number
) {
if (!link || !link.collectionId)
return { response: "Please choose a valid link.", status: 401 };
export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
const collectionIsAccessible = (await getPermission(
userId,
link.collectionId
)) as
const collectionIsAccessible = (await getPermission({ userId, linkId })) as
| (Collection & {
members: UsersAndCollections[];
})
@@ -29,12 +21,19 @@ export default async function deleteLink(
const deleteLink: Link = await prisma.link.delete({
where: {
id: link.id,
id: linkId,
},
});
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
return { response: deleteLink, status: 200 };
}
@@ -0,0 +1,48 @@
import { prisma } from "@/lib/api/db";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
export default async function getLinkById(userId: number, linkId: number) {
if (!linkId)
return {
response: "Please choose a valid link.",
status: 401,
};
const collectionIsAccessible = (await getPermission({ userId, linkId })) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
);
const isCollectionOwner = collectionIsAccessible?.ownerId === userId;
if (collectionIsAccessible?.ownerId !== userId && !memberHasAccess)
return {
response: "Collection is not accessible.",
status: 401,
};
else {
const updatedLink = await prisma.link.findUnique({
where: {
id: linkId,
},
include: {
tags: true,
collection: true,
pinnedBy: isCollectionOwner
? {
where: { id: userId },
select: { id: true },
}
: undefined,
},
});
return { response: updatedLink, status: 200 };
}
}
@@ -0,0 +1,114 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import moveFile from "@/lib/api/storage/moveFile";
export default async function updateLinkById(
userId: number,
linkId: number,
data: LinkIncludingShortenedCollectionAndTags
) {
if (!data || !data.collection.id)
return {
response: "Please choose a valid link and collection.",
status: 401,
};
const collectionIsAccessible = (await getPermission({ userId, linkId })) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
);
const isCollectionOwner =
collectionIsAccessible?.ownerId === data.collection.ownerId &&
data.collection.ownerId === userId;
const unauthorizedSwitchCollection =
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
// Makes sure collection members (non-owners) cannot move a link to/from a collection.
if (unauthorizedSwitchCollection)
return {
response: "You can't move a link to/from a collection you don't own.",
status: 401,
};
else if (collectionIsAccessible?.ownerId !== userId && !memberHasAccess)
return {
response: "Collection is not accessible.",
status: 401,
};
else {
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
name: data.name,
description: data.description,
collection: {
connect: {
id: data.collection.id,
},
},
tags: {
set: [],
connectOrCreate: data.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name,
ownerId: data.collection.ownerId,
},
},
create: {
name: tag.name,
owner: {
connect: {
id: data.collection.ownerId,
},
},
},
})),
},
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
? { connect: { id: userId } }
: { disconnect: { id: userId } },
},
include: {
tags: true,
collection: true,
pinnedBy: isCollectionOwner
? {
where: { id: userId },
select: { id: true },
}
: undefined,
},
});
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`
);
}
return { response: updatedLink, status: 200 };
}
}
+10 -9
View File
@@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import getTitle from "@/lib/api/getTitle";
import archive from "@/lib/api/archive";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import { Collection, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import createFolder from "@/lib/api/storage/createFolder";
@@ -20,17 +20,17 @@ export default async function postLink(
};
}
link.collection.name = link.collection.name.trim();
if (!link.collection.name) {
link.collection.name = "Unnamed Collection";
link.collection.name = "Unorganized";
}
link.collection.name = link.collection.name.trim();
if (link.collection.id) {
const collectionIsAccessible = (await getPermission(
const collectionIsAccessible = (await getPermission({
userId,
link.collection.id
)) as
collectionId: link.collection.id,
})) as
| (Collection & {
members: UsersAndCollections[];
})
@@ -51,11 +51,12 @@ export default async function postLink(
? link.description
: await getTitle(link.url);
const newLink: Link = await prisma.link.create({
const newLink = await prisma.link.create({
data: {
url: link.url,
name: link.name,
description,
readabilityPath: "pending",
collection: {
connectOrCreate: {
where: {
@@ -94,7 +95,7 @@ export default async function postLink(
createFolder({ filePath: `archives/${newLink.collectionId}` });
archive(newLink.url, newLink.collectionId, newLink.id);
archive(newLink.id, newLink.url, userId);
return { response: newLink, status: 200 };
}
-103
View File
@@ -1,103 +0,0 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
export default async function updateLink(
link: LinkIncludingShortenedCollectionAndTags,
userId: number
) {
if (!link || !link.collection.id)
return {
response: "Please choose a valid link and collection.",
status: 401,
};
const targetLink = (await getPermission(
userId,
link.collection.id,
link.id
)) as
| (Link & {
collection: Collection & {
members: UsersAndCollections[];
};
})
| null;
const memberHasAccess = targetLink?.collection.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
);
const isCollectionOwner =
targetLink?.collection.ownerId === link.collection.ownerId &&
link.collection.ownerId === userId &&
targetLink?.collection.ownerId === userId;
// Makes sure collection members (non-owners) cannot move a link to/from a collection.
if (!isCollectionOwner)
return {
response: "You can't move a link to/from a collection you don't own.",
status: 401,
};
else if (targetLink?.collection.ownerId !== userId && !memberHasAccess)
return {
response: "Collection is not accessible.",
status: 401,
};
else {
const updatedLink = await prisma.link.update({
where: {
id: link.id,
},
data: {
name: link.name,
description: link.description,
collection:
targetLink?.collection.ownerId === link.collection.ownerId &&
link.collection.ownerId === userId
? {
connect: {
id: link.collection.id,
},
}
: undefined,
tags: {
set: [],
connectOrCreate: link.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name,
ownerId: link.collection.ownerId,
},
},
create: {
name: tag.name,
owner: {
connect: {
id: link.collection.ownerId,
},
},
},
})),
},
pinnedBy:
link?.pinnedBy && link.pinnedBy[0]
? { connect: { id: userId } }
: { disconnect: { id: userId } },
},
include: {
tags: true,
collection: true,
pinnedBy: isCollectionOwner
? {
where: { id: userId },
select: { id: true },
}
: undefined,
},
});
return { response: updatedLink, status: 200 };
}
}
@@ -0,0 +1,37 @@
import { prisma } from "@/lib/api/db";
export default async function exportData(userId: number) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
collections: {
include: {
links: {
include: {
tags: true,
},
},
},
},
},
});
if (!user) return { response: "User not found.", status: 404 };
const { password, id, ...userData } = user;
function redactIds(obj: any) {
if (Array.isArray(obj)) {
obj.forEach((o) => redactIds(o));
} else if (obj !== null && typeof obj === "object") {
delete obj.id;
for (let key in obj) {
redactIds(obj[key]);
}
}
}
redactIds(userData);
return { response: userData, status: 200 };
}
@@ -0,0 +1,103 @@
import { prisma } from "@/lib/api/db";
import { Backup } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder";
import { JSDOM } from "jsdom";
export default async function importFromHTMLFile(
userId: number,
rawData: string
) {
const dom = new JSDOM(rawData);
const document = dom.window.document;
const folders = document.querySelectorAll("H3");
await prisma
.$transaction(
async () => {
// @ts-ignore
for (const folder of folders) {
const findCollection = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collections: {
where: {
name: folder.textContent.trim(),
},
},
},
});
const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists || !collectionId) {
const newCollection = await prisma.collection.create({
data: {
name: folder.textContent.trim(),
description: "",
color: "#0ea5e9",
isPublic: false,
ownerId: userId,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
createFolder({ filePath: `archives/${collectionId}` });
const bookmarks = folder.nextElementSibling.querySelectorAll("A");
for (const bookmark of bookmarks) {
await prisma.link.create({
data: {
name: bookmark.textContent.trim(),
url: bookmark.getAttribute("HREF"),
tags: bookmark.getAttribute("TAGS")
? {
connectOrCreate: bookmark
.getAttribute("TAGS")
.split(",")
.map((tag: string) =>
tag
? {
where: {
name_ownerId: {
name: tag.trim(),
ownerId: userId,
},
},
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
},
},
},
}
: undefined
),
}
: undefined,
description: bookmark.getAttribute("DESCRIPTION")
? bookmark.getAttribute("DESCRIPTION")
: "",
collectionId: collectionId,
createdAt: new Date(),
},
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 };
}
@@ -0,0 +1,92 @@
import { prisma } from "@/lib/api/db";
import { Backup } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder";
export default async function getData(userId: number, rawData: string) {
const data: Backup = JSON.parse(rawData);
await prisma
.$transaction(
async () => {
// Import collections
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 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;
}
// Import Links
for (const link of e.links) {
const newLink = await prisma.link.create({
data: {
url: link.url,
name: link.name,
description: link.description,
collection: {
connect: {
id: collectionId,
},
},
// Import Tags
tags: {
connectOrCreate: link.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name.trim(),
ownerId: userId,
},
},
create: {
name: tag.name.trim(),
owner: {
connect: {
id: userId,
},
},
},
})),
},
},
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 };
}
@@ -0,0 +1,61 @@
import { prisma } from "@/lib/api/db";
export default async function getPublicUserById(
targetId: number | string,
isId: boolean,
requestingId?: number
) {
const user = await prisma.user.findUnique({
where: isId
? {
id: Number(targetId) as number,
}
: {
username: targetId as string,
},
include: {
whitelistedUsers: {
select: {
username: true,
},
},
},
});
if (!user)
return { response: "User not found or profile is private.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(
(usernames) => usernames.username
);
if (user?.isPrivate) {
if (requestingId) {
const requestingUsername = (
await prisma.user.findUnique({ where: { id: requestingId } })
)?.username;
if (
!requestingUsername ||
!whitelistedUsernames.includes(requestingUsername?.toLowerCase())
) {
return {
response: "User not found or profile is private.",
status: 404,
};
}
} else
return { response: "User not found or profile is private.", status: 404 };
}
const { password, ...lessSensitiveInfo } = user;
const data = {
id: lessSensitiveInfo.id,
name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username,
image: lessSensitiveInfo.image,
};
return { response: data, status: 200 };
}
@@ -0,0 +1,26 @@
import { prisma } from "@/lib/api/db";
export default async function deleteTagById(userId: number, tagId: number) {
if (!tagId)
return { response: "Please choose a valid name for the tag.", status: 401 };
const targetTag = await prisma.tag.findUnique({
where: {
id: tagId,
},
});
if (targetTag?.ownerId !== userId)
return {
response: "Permission denied.",
status: 401,
};
const updatedTag = await prisma.tag.delete({
where: {
id: tagId,
},
});
return { response: updatedTag, status: 200 };
}
@@ -0,0 +1,47 @@
import { prisma } from "@/lib/api/db";
import { Tag } from "@prisma/client";
export default async function updeteTagById(
userId: number,
tagId: number,
data: Tag
) {
if (!tagId || !data.name)
return { response: "Please choose a valid name for the tag.", status: 401 };
const tagNameIsTaken = await prisma.tag.findFirst({
where: {
ownerId: userId,
name: data.name,
},
});
if (tagNameIsTaken)
return {
response: "Tag names should be unique.",
status: 400,
};
const targetTag = await prisma.tag.findUnique({
where: {
id: tagId,
},
});
if (targetTag?.ownerId !== userId)
return {
response: "Permission denied.",
status: 401,
};
const updatedTag = await prisma.tag.update({
where: {
id: tagId,
},
data: {
name: data.name,
},
});
return { response: updatedTag, status: 200 };
}
-45
View File
@@ -1,45 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getUser({
params,
isSelf,
username,
}: {
params: {
lookupUsername?: string;
lookupId?: number;
};
isSelf: boolean;
username: string;
}) {
const user = await prisma.user.findUnique({
where: {
id: params.lookupId,
username: params.lookupUsername?.toLowerCase(),
},
});
if (!user) return { response: "User not found.", status: 404 };
if (
!isSelf &&
user?.isPrivate &&
!user.whitelistedUsers.includes(username.toLowerCase())
) {
return { response: "This profile is private.", status: 401 };
}
const { password, ...lessSensitiveInfo } = user;
const data = isSelf
? // If user is requesting its own data
lessSensitiveInfo
: {
// If user is requesting someone elses data
id: lessSensitiveInfo.id,
name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username,
};
return { response: data || null, status: 200 };
}
@@ -16,10 +16,14 @@ interface User {
password: string;
}
export default async function Index(
export default async function postUser(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
return res.status(400).json({ response: "Registration is disabled." });
}
const body: User = req.body;
const checkHasEmptyFields = emailEnabled
@@ -31,8 +35,16 @@ export default async function Index(
.status(400)
.json({ response: "Please fill out all the fields." });
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
// Check email (if enabled)
const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
if (emailEnabled && !checkEmail.test(body.email?.toLowerCase() || ""))
return res.status(400).json({
response: "Please enter a valid email.",
});
// Check username (if email was disabled)
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || ""))
return res.status(400).json({
response:
@@ -42,11 +54,10 @@ export default async function Index(
const checkIfUserExists = await prisma.user.findFirst({
where: emailEnabled
? {
email: body.email?.toLowerCase(),
emailVerified: { not: null },
email: body.email?.toLowerCase().trim(),
}
: {
username: (body.username as string).toLowerCase(),
username: (body.username as string).toLowerCase().trim(),
},
});
@@ -60,16 +71,16 @@ export default async function Index(
name: body.name,
username: emailEnabled
? undefined
: (body.username as string).toLowerCase(),
email: emailEnabled ? body.email?.toLowerCase() : 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: "Username and/or Email already exists." });
return res.status(400).json({
response: `${emailEnabled ? "Email" : "Username"} already exists.`,
});
}
}
-121
View File
@@ -1,121 +0,0 @@
import { prisma } from "@/lib/api/db";
import { AccountSettings } from "@/types/global";
import bcrypt from "bcrypt";
import removeFile from "@/lib/api/storage/removeFile";
import createFile from "@/lib/api/storage/createFile";
import updateCustomerEmail from "../../updateCustomerEmail";
export default async function updateUser(
user: AccountSettings,
sessionUser: {
id: number;
username: string;
email: string;
isSubscriber: boolean;
}
) {
if (!user.username || !user.email)
return {
response: "Username/Email invalid.",
status: 400,
};
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!checkUsername.test(user.username.toLowerCase()))
return {
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
status: 400,
};
const userIsTaken = await prisma.user.findFirst({
where: {
id: { not: sessionUser.id },
OR: [
{
username: user.username.toLowerCase(),
},
{
email: user.email.toLowerCase(),
},
],
},
});
if (userIsTaken)
return {
response: "Username/Email is taken.",
status: 400,
};
// Avatar Settings
const profilePic = user.profilePic;
if (profilePic.startsWith("data:image/jpeg;base64")) {
if (user.profilePic.length < 1572864) {
try {
const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, "");
await createFile({
filePath: `uploads/avatar/${sessionUser.id}.jpg`,
data: base64Data,
isBase64: true,
});
} catch (err) {
console.log("Error saving image:", err);
}
} else {
console.log("A file larger than 1.5MB was uploaded.");
return {
response: "A file larger than 1.5MB was uploaded.",
status: 400,
};
}
} else if (profilePic == "") {
removeFile({ filePath: `uploads/avatar/${sessionUser.id}.jpg` });
}
// Other settings
const saltRounds = 10;
const newHashedPassword = bcrypt.hashSync(user.newPassword || "", saltRounds);
const updatedUser = await prisma.user.update({
where: {
id: sessionUser.id,
},
data: {
name: user.name,
username: user.username.toLowerCase(),
email: user.email?.toLowerCase(),
isPrivate: user.isPrivate,
whitelistedUsers: user.whitelistedUsers,
password:
user.newPassword && user.newPassword !== ""
? newHashedPassword
: undefined,
},
});
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const PRICE_ID = process.env.PRICE_ID;
if (STRIPE_SECRET_KEY && PRICE_ID)
await updateCustomerEmail(
STRIPE_SECRET_KEY,
PRICE_ID,
sessionUser.email,
user.email
);
const { password, ...userInfo } = updatedUser;
const response: Omit<AccountSettings, "password"> = {
...userInfo,
profilePic: `/api/avatar/${userInfo.id}?${Date.now()}`,
};
return { response, status: 200 };
}
@@ -0,0 +1,127 @@
import { prisma } from "@/lib/api/db";
import bcrypt from "bcrypt";
import removeFolder from "@/lib/api/storage/removeFolder";
import Stripe from "stripe";
import { DeleteUserBody } from "@/types/global";
import removeFile from "@/lib/api/storage/removeFile";
export default async function deleteUserById(
userId: number,
body: DeleteUserBody
) {
// First, we retrieve the user from the database
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
return {
response: "Invalid credentials.",
status: 404,
};
}
// Then, we check if the provided password matches the one stored in the database
const isPasswordValid = bcrypt.compareSync(body.password, user.password);
if (!isPasswordValid) {
return {
response: "Invalid credentials.",
status: 401, // Unauthorized
};
}
// Delete the user and all related data within a transaction
await prisma
.$transaction(
async (prisma) => {
// Delete whitelisted users
await prisma.whitelistedUser.deleteMany({
where: { userId },
});
// Delete links
await prisma.link.deleteMany({
where: { collection: { ownerId: userId } },
});
// Delete tags
await prisma.tag.deleteMany({
where: { ownerId: userId },
});
// Find collections that the user owns
const collections = await prisma.collection.findMany({
where: { ownerId: userId },
});
for (const collection of collections) {
// Delete related users and collections relations
await prisma.usersAndCollections.deleteMany({
where: { collectionId: collection.id },
});
// Delete archive folders
removeFolder({ filePath: `archives/${collection.id}` });
}
// Delete collections after cleaning up related data
await prisma.collection.deleteMany({
where: { ownerId: userId },
});
// Delete subscription
if (process.env.STRIPE_SECRET_KEY)
await prisma.subscription.delete({
where: { userId },
});
// Delete user's avatar
await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
// Finally, delete the user
await prisma.user.delete({
where: { id: userId },
});
},
{ timeout: 20000 }
)
.catch((err) => console.log(err));
if (process.env.STRIPE_SECRET_KEY) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
try {
const listByEmail = await stripe.customers.list({
email: user.email?.toLowerCase(),
expand: ["data.subscriptions"],
});
if (listByEmail.data[0].subscriptions?.data[0].id) {
const deleted = await stripe.subscriptions.cancel(
listByEmail.data[0].subscriptions?.data[0].id,
{
cancellation_details: {
comment: body.cancellation_details?.comment,
feedback: body.cancellation_details?.feedback,
},
}
);
return {
response: deleted,
status: 200,
};
}
} catch (err) {
console.log(err);
}
}
return {
response: "User account and all related data deleted successfully.",
status: 200,
};
}
@@ -0,0 +1,36 @@
import { prisma } from "@/lib/api/db";
export default async function getUserById(userId: number) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
whitelistedUsers: {
select: {
username: true,
},
},
subscriptions: true,
},
});
if (!user)
return { response: "User not found or profile is private.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(
(usernames) => usernames.username
);
const { password, subscriptions, ...lessSensitiveInfo } = user;
const data = {
...lessSensitiveInfo,
whitelistedUsers: whitelistedUsernames,
subscription: {
active: subscriptions?.active,
},
};
return { response: data, status: 200 };
}
@@ -0,0 +1,207 @@
import { prisma } from "@/lib/api/db";
import { AccountSettings } from "@/types/global";
import bcrypt from "bcrypt";
import removeFile from "@/lib/api/storage/removeFile";
import createFile from "@/lib/api/storage/createFile";
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
import createFolder from "@/lib/api/storage/createFolder";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
export default async function updateUserById(
userId: number,
data: AccountSettings
) {
if (emailEnabled && !data.email)
return {
response: "Email invalid.",
status: 400,
};
else if (!data.username)
return {
response: "Username invalid.",
status: 400,
};
// Check email (if enabled)
const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
if (emailEnabled && !checkEmail.test(data.email?.toLowerCase() || ""))
return {
response: "Please enter a valid email.",
status: 400,
};
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!checkUsername.test(data.username.toLowerCase()))
return {
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
status: 400,
};
const userIsTaken = await prisma.user.findFirst({
where: {
id: { not: userId },
OR: emailEnabled
? [
{
username: data.username.toLowerCase(),
},
{
email: data.email?.toLowerCase(),
},
]
: [
{
username: data.username.toLowerCase(),
},
],
},
});
if (userIsTaken) {
if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim())
return {
response: "Email is taken.",
status: 400,
};
else if (
data.username?.toLowerCase().trim() === userIsTaken.username?.trim()
)
return {
response: "Username is taken.",
status: 400,
};
return {
response: "Username/Email is taken.",
status: 400,
};
}
// Avatar Settings
if (data.image?.startsWith("data:image/jpeg;base64")) {
if (data.image.length < 1572864) {
try {
const base64Data = data.image.replace(/^data:image\/jpeg;base64,/, "");
createFolder({ filePath: `uploads/avatar` });
await createFile({
filePath: `uploads/avatar/${userId}.jpg`,
data: base64Data,
isBase64: true,
});
} catch (err) {
console.log("Error saving image:", err);
}
} else {
console.log("A file larger than 1.5MB was uploaded.");
return {
response: "A file larger than 1.5MB was uploaded.",
status: 400,
};
}
} else if (data.image == "") {
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
}
const previousEmail = (
await prisma.user.findUnique({ where: { id: userId } })
)?.email;
// Other settings
const saltRounds = 10;
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
const updatedUser = await prisma.user.update({
where: {
id: userId,
},
data: {
name: data.name,
username: data.username.toLowerCase().trim(),
email: data.email?.toLowerCase().trim(),
isPrivate: data.isPrivate,
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
displayLinkIcons: data.displayLinkIcons,
blurredFavicons: data.blurredFavicons,
password:
data.newPassword && data.newPassword !== ""
? newHashedPassword
: undefined,
},
include: {
whitelistedUsers: true,
subscriptions: true,
},
});
const { whitelistedUsers, password, subscriptions, ...userInfo } =
updatedUser;
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
// Get the current whitelisted usernames
const currentWhitelistedUsernames: string[] = whitelistedUsers.map(
(data) => data.username
);
// Find the usernames to be deleted (present in current but not in new)
const usernamesToDelete: string[] = currentWhitelistedUsernames.filter(
(username) => !newWhitelistedUsernames.includes(username)
);
// Find the usernames to be created (present in new but not in current)
const usernamesToCreate: string[] = newWhitelistedUsernames.filter(
(username) =>
!currentWhitelistedUsernames.includes(username) && username.trim() !== ""
);
// Delete whitelistedUsers that are not present in the new list
await prisma.whitelistedUser.deleteMany({
where: {
userId: userId,
username: {
in: usernamesToDelete,
},
},
});
// Create new whitelistedUsers that are not in the current list, no create many ;(
for (const username of usernamesToCreate) {
await prisma.whitelistedUser.create({
data: {
username,
userId: userId,
},
});
}
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (STRIPE_SECRET_KEY && emailEnabled && previousEmail !== data.email)
await updateCustomerEmail(
STRIPE_SECRET_KEY,
previousEmail as string,
data.email as string
);
const response: Omit<AccountSettings, "password"> = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
subscription: { active: subscriptions?.active },
};
return { response, status: 200 };
}
+7 -6
View File
@@ -10,9 +10,10 @@ export const prisma =
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
if (process.env.NODE_ENV !== "production")
prisma.$on("query" as any, (e: any) => {
console.log("Query: " + e.query);
console.log("Params: " + e.params);
console.log("\x1b[31m", `Duration: ${e.duration}ms`, "\x1b[0m"); // For benchmarking
});
// For benchmarking | uncomment when needed
// if (process.env.NODE_ENV !== "production")
// prisma.$on("query" as any, (e: any) => {
// console.log("Query: " + e.query);
// console.log("Params: " + e.params);
// console.log("\x1b[31m", `Duration: ${e.duration}ms`, "\x1b[0m");
// });
+19 -13
View File
@@ -1,24 +1,30 @@
import { prisma } from "@/lib/api/db";
export default async function getPermission(
userId: number,
collectionId: number,
linkId?: number
) {
type Props = {
userId: number;
collectionId?: number;
linkId?: number;
};
export default async function getPermission({
userId,
collectionId,
linkId,
}: Props) {
if (linkId) {
const link = await prisma.link.findUnique({
const check = await prisma.collection.findFirst({
where: {
id: linkId,
},
include: {
collection: {
include: { members: true },
links: {
some: {
id: linkId,
},
},
},
include: { members: true },
});
return link;
} else {
return check;
} else if (collectionId) {
const check = await prisma.collection.findFirst({
where: {
AND: {
+122
View File
@@ -0,0 +1,122 @@
const { S3 } = require("@aws-sdk/client-s3");
const { PrismaClient } = require("@prisma/client");
const { existsSync } = require("fs");
const util = require("util");
const prisma = new PrismaClient();
const STORAGE_FOLDER = process.env.STORAGE_FOLDER || "data";
const s3Client =
process.env.SPACES_ENDPOINT &&
process.env.SPACES_REGION &&
process.env.SPACES_KEY &&
process.env.SPACES_SECRET
? new S3({
forcePathStyle: false,
endpoint: process.env.SPACES_ENDPOINT,
region: process.env.SPACES_REGION,
credentials: {
accessKeyId: process.env.SPACES_KEY,
secretAccessKey: process.env.SPACES_SECRET,
},
})
: undefined;
async function checkFileExistence(path) {
if (s3Client) {
const bucketParams = {
Bucket: process.env.BUCKET_NAME,
Key: path,
};
try {
const headObjectAsync = util.promisify(
s3Client.headObject.bind(s3Client)
);
try {
await headObjectAsync(bucketParams);
return true;
} catch (err) {
return false;
}
} catch (err) {
console.log("Error:", err);
return false;
}
} else {
try {
if (existsSync(STORAGE_FOLDER + "/" + path)) {
return true;
} else return false;
} catch (err) {
console.log(err);
}
}
}
// Avatars
async function migrateToV2() {
const users = await prisma.user.findMany();
for (let user of users) {
const path = `uploads/avatar/${user.id}.jpg`;
const res = await checkFileExistence(path);
if (res) {
await prisma.user.update({
where: { id: user.id },
data: { image: path },
});
console.log(`${user.id}`);
} else {
console.log(`${user.id}`);
}
}
const links = await prisma.link.findMany();
// PDFs
for (let link of links) {
const path = `archives/${link.collectionId}/${link.id}.pdf`;
const res = await checkFileExistence(path);
if (res) {
await prisma.link.update({
where: { id: link.id },
data: { pdfPath: path },
});
console.log(`${link.id}`);
} else {
console.log(`${link.id}`);
}
}
// Screenshots
for (let link of links) {
const path = `archives/${link.collectionId}/${link.id}.png`;
const res = await checkFileExistence(path);
if (res) {
await prisma.link.update({
where: { id: link.id },
data: { screenshotPath: path },
});
console.log(`${link.id}`);
} else {
console.log(`${link.id}`);
}
}
await prisma.$disconnect();
}
migrateToV2().catch((e) => {
console.error(e);
process.exit(1);
});
+3 -4
View File
@@ -1,5 +1,4 @@
import Stripe from "stripe";
import checkSubscription from "./checkSubscription";
export default async function paymentCheckout(
stripeSecretKey: string,
@@ -15,12 +14,12 @@ export default async function paymentCheckout(
expand: ["data.subscriptions"],
});
const isExistingCostomer = listByEmail?.data[0]?.id || undefined;
const isExistingCustomer = listByEmail?.data[0]?.id || undefined;
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
const session = await stripe.checkout.sessions.create({
customer: isExistingCostomer ? isExistingCostomer : undefined,
customer: isExistingCustomer ? isExistingCustomer : undefined,
line_items: [
{
price: priceId,
@@ -28,7 +27,7 @@ export default async function paymentCheckout(
},
],
mode: "subscription",
customer_email: isExistingCostomer ? undefined : email.toLowerCase(),
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL}/login`,
automatic_tax: {
+23
View File
@@ -0,0 +1,23 @@
import axios from "axios";
export default async function sendToWayback(url: string) {
const headers = {
Accept: "text/html,application/xhtml+xml,application/xml",
"Accept-Encoding": "gzip, deflate",
Dnt: "1",
"Upgrade-Insecure-Requests": "1",
"User-Agent":
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
};
await axios
.get(`https://web.archive.org/save/${url}`, {
headers: headers,
})
.then((response) => {
console.log(response.data);
})
.catch((error) => {
console.error(error);
});
}

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