Compare commits

...

192 Commits

Author SHA1 Message Date
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
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
131 changed files with 4442 additions and 2739 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
+7 -2
View File
@@ -6,6 +6,8 @@ NEXTAUTH_URL=http://localhost:3000
PAGINATION_TAKE_COUNT=
STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION=
# AWS S3 Settings
SPACES_KEY=
@@ -21,8 +23,11 @@ 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=
MONTHLY_PRICE_ID=
YEARLY_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
-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
+22
View File
@@ -0,0 +1,22 @@
# playwright doesnt support debian image
FROM node:20-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
RUN mkdir /data
WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN yarn && \
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 and a PDF of each link.
- 🏛️ Send your webpage to Wayback Machine archive.org for a snapshot.
- 📂 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.
- 🔍 Search, filter and sort by link details.
- 📱 Responsive design and supports most browsers.
- 🌓 Dark/Light mode support.
- 🧩 Browser extension, managed by the community [check it out!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import your bookmarks from other browsers.
## 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).
- Following @daniel31x13 on [Mastodon](https://mastodon.social/@daniel31x13), [Twitter](https://twitter.com/daniel31x13) and [GitHub](https://github.com/daniel31x13).
- 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

+6 -6
View File
@@ -11,9 +11,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}`}
>
<label className={`cursor-pointer flex items-center gap-2 ${className}`}>
<input
type="checkbox"
checked={state}
@@ -22,13 +20,15 @@ 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="text-black dark:text-white rounded select-none">
{label}
</span>
</label>
);
}
+19 -8
View File
@@ -8,6 +8,7 @@ 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;
@@ -17,6 +18,8 @@ type Props = {
export default function CollectionCard({ collection, className }: Props) {
const { setModal } = useModalStore();
const { theme } = useTheme();
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US",
{
@@ -32,24 +35,29 @@ export default function CollectionCard({ collection, className }: Props) {
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}`}
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}`}
>
<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"
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"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</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">
<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">
@@ -67,17 +75,20 @@ export default function CollectionCard({ collection, className }: Props) {
})
.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">
<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-sky-700 font-bold text-sm flex justify-end gap-1 items-center">
<FontAwesomeIcon icon={faLink} className="w-5 h-5 text-sky-500" />
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
<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-600">
<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>
+3 -3
View File
@@ -25,13 +25,13 @@ export default function Dropdown({ onClickOutside, className, items }: Props) {
return (
<ClickAwayHandler
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>
);
+4 -2
View File
@@ -20,9 +20,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"
@@ -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}
+37 -22
View File
@@ -19,6 +19,8 @@ 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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@@ -106,7 +108,7 @@ 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}`}
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}`}
>
{(permissions === true ||
permissions?.canUpdate ||
@@ -114,7 +116,7 @@ export default function LinkCard({ link, count, className }: Props) {
<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"
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}
@@ -136,7 +138,7 @@ export default function LinkCard({ link, count, className }: Props) {
active: link,
});
}}
className="flex items-start gap-5 sm:gap-10 h-full w-full p-5"
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5"
>
{url && (
<Image
@@ -144,7 +146,7 @@ export default function LinkCard({ link, count, className }: Props) {
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"
className="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;
@@ -156,28 +158,41 @@ export default function LinkCard({ link, count, className }: Props) {
<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 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>
<div className="flex gap-3 items-center my-3">
<div className="flex items-center gap-1 w-full pr-20">
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }}
/>
<p className="text-sky-900 truncate capitalize">
{collection?.name}
</p>
</div>
</div>
<div className="flex items-center gap-1 w-full pr-20 text-gray-500">
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit my-3 hover:opacity-70 duration-100"
>
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }}
/>
<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>
</div>
<div className="flex items-center gap-1 text-gray-500">
</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>
+10 -9
View File
@@ -11,6 +11,7 @@ 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 +61,23 @@ 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">
<p className="text-sm text-black dark:text-white mb-2">
Name
<RequiredBadge />
</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="text-sm w-full text-black dark:text-white mb-2">
Icon Color
</p>
<div style={{ color: collection.color }}>
<FontAwesomeIcon
icon={faFolder}
@@ -84,7 +85,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 +102,9 @@ export default function CollectionInfo({
</div>
<div className="w-full">
<p className="text-sm text-sky-700 mb-2">Description</p>
<p className="text-sm 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}
>
+42 -35
View File
@@ -17,6 +17,7 @@ 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";
type Props = {
toggleCollectionModal: Function;
@@ -117,7 +118,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-sm text-black dark:text-white">Make Public</p>
<Checkbox
label="Make this a public collection."
@@ -127,7 +128,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 +136,7 @@ export default function TeamManagement({
{collection.isPublic ? (
<div>
<p className="text-sm text-sky-700 mb-2">
<p className="text-sm text-black dark:text-white mb-2">
Public Link (Click to copy)
</p>
<div
@@ -148,22 +149,27 @@ 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-sm text-black dark:text-white">
Member Management
</p>
<div className="flex items-center gap-2">
<input
<TextInput
value={member.user.username || ""}
placeholder="Username (without the '@')"
onChange={(e) => {
setMember({
...member,
@@ -179,9 +185,6 @@ export default function TeamManagement({
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
@@ -193,7 +196,7 @@ export default function TeamManagement({
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 +206,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 +216,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(
@@ -239,23 +242,25 @@ export default function TeamManagement({
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 +270,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 +310,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 +355,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 +400,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,7 +420,7 @@ 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">
@@ -425,7 +430,7 @@ export default function TeamManagement({
/>
<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 +438,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"}
+152 -84
View File
@@ -12,6 +12,8 @@ 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";
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>(
@@ -50,22 +56,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,
},
});
}
@@ -115,94 +134,143 @@ export default function AddOrEditLink({
<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"
className="text-gray-500 dark:text-gray-300 text-center truncate w-full"
title={link.url}
>
<Link href={link.url} target="_blank" className=" font-bold">
Editing:{" "}
<Link href={link.url} target="_blank">
{link.url}
</Link>
</p>
) : 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-sm text-black dark:text-white mb-2 font-bold">
Address (URL)
<RequiredBadge />
</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-sm 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-sm 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-sm 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-sm 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-sm 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-center 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 py-1 px-2 w-fit text-sm`}
>
{optionsExpanded ? "Hide" : "More"} Options
</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>
);
}
+108 -69
View File
@@ -12,6 +12,7 @@ import {
faBoxArchive,
faCloudArrowDown,
faFolder,
faGlobe,
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import {
@@ -20,12 +21,17 @@ import {
faFilePdf,
} from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/client/isValidUrl";
import { useTheme } from "next-themes";
import unescapeString from "@/lib/client/unescapeString";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
isOwnerOrMod: boolean;
};
export default function LinkDetails({ link }: Props) {
export default function LinkDetails({ link, isOwnerOrMod }: Props) {
const { theme } = useTheme();
const [imageError, setImageError] = useState<boolean>(false);
const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US",
@@ -73,27 +79,31 @@ export default function LinkDetails({ link }: Props) {
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]
)})`;
if (colorPalette[0] && colorPalette[1]) {
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]
)})`;
if (colorPalette[2] && colorPalette[3]) {
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]);
}, [colorPalette, theme]);
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/archives/${link.collection.id}/${link.id}.${format}`;
@@ -115,7 +125,11 @@ export default function LinkDetails({ link }: Props) {
};
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<div
className={`flex flex-col gap-3 sm:w-[35rem] w-80 ${
isOwnerOrMod ? "" : "mt-12"
} ${theme === "dark" ? "banner-dark-mode" : "banner-light-mode"}`}
>
{!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>
@@ -131,7 +145,7 @@ export default function LinkDetails({ link }: Props) {
height={42}
alt=""
id={"favicon-" + link.id}
className="select-none mt-2 rounded-md shadow border-[3px] border-white bg-white aspect-square"
className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-white dark:border-neutral-900 bg-white dark:bg-neutral-900 aspect-square"
draggable="false"
onLoad={(e) => {
try {
@@ -150,15 +164,16 @@ export default function LinkDetails({ link }: Props) {
}}
/>
)}
<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}
<div className="flex w-full flex-col min-h-[3rem] justify-center drop-shadow">
<p className="text-2xl text-black dark:text-white capitalize break-words hyphens-auto">
{unescapeString(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"
className={`${
link.name ? "text-sm" : "text-xl"
} text-gray-500 dark:text-gray-300 break-all hover:underline cursor-pointer w-fit`}
>
{url ? url.host : link.url}
</Link>
@@ -176,7 +191,7 @@ export default function LinkDetails({ link }: Props) {
/>
<p
title={collection?.name}
className="text-sky-900 text-lg truncate max-w-[12rem]"
className="text-black dark:text-white text-lg truncate max-w-[12rem]"
>
{collection?.name}
</p>
@@ -185,7 +200,7 @@ export default function LinkDetails({ link }: Props) {
<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]"
className="px-2 py-1 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</p>
@@ -194,19 +209,19 @@ export default function LinkDetails({ link }: Props) {
</div>
{link.description && (
<>
<div className="text-gray-500 max-h-[20rem] my-3 rounded-md overflow-y-auto hyphens-auto">
{link.description}
<div className="text-black dark:text-white max-h-[20rem] my-3 rounded-md overflow-y-auto hyphens-auto">
{unescapeString(link.description)}
</div>
</>
)}
<div className="flex justify-between items-center">
<div className="flex items-center gap-1 text-gray-500">
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faBoxArchive} className="w-4 h-4" />
<p className=" text-gray-500">Archived Formats:</p>
<p>Archived Formats:</p>
</div>
<div
className="flex items-center gap-1 text-gray-500"
className="flex items-center gap-1 text-gray-500 dark:text-gray-300"
title={"Created at: " + formattedDate}
>
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
@@ -214,73 +229,97 @@ export default function LinkDetails({ link }: Props) {
</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 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 p-2 rounded-md">
<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-gray-500">Screenshot</p>
<p className="text-black dark:text-white">Screenshot</p>
</div>
<div className="flex text-sky-500 gap-1">
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-sky-500 dark:text-sky-500"
/>
</div>
<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"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5"
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</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 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 p-2 rounded-md">
<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-gray-500">PDF</p>
<p className="text-black dark:text-white">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 className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:bg-slate-200 duration-100 p-2 rounded-md"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer"
className="w-5 h-5 cursor-pointer text-sky-500 dark:text-sky-500"
/>
</div>
<Link
href={`/api/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
</div>
</div>
<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={faGlobe} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">
Latest archive.org Snapshot
</p>
</div>
<Link
href={`https://web.archive.org/web/${link.url.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
</div>
</div>
</div>
);
+9 -11
View File
@@ -33,20 +33,18 @@ export default function LinkModal({
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="text-xl text-sky-700 text-center">New Link</p>
<p className="text-xl text-black dark:text-white 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"
}`}
>
<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" && 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"
? "px-2 py-1 bg-sky-200 dark:bg-sky-800 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 rounded-md duration-100 outline-none"
}
>
Link Details
@@ -54,8 +52,8 @@ export default function LinkModal({
<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 duration-100 rounded-md outline-none"
: "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 rounded-md duration-100 outline-none"
}
>
Edit Link
@@ -66,7 +64,7 @@ export default function LinkModal({
<Tab.Panels>
{activeLink && method === "UPDATE" && (
<Tab.Panel>
<LinkDetails link={activeLink} />
<LinkDetails link={activeLink} isOwnerOrMod={isOwnerOrMod} />
</Tab.Panel>
)}
-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}
-12
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";
@@ -49,15 +47,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 <></>;
}
+26 -15
View File
@@ -10,6 +10,7 @@ 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";
export default function Navbar() {
const { setModal } = useModalStore();
@@ -18,10 +19,20 @@ export default function Navbar() {
const [profileDropdown, setProfileDropdown] = useState(false);
const [sidebar, setSidebar] = useState(false);
const router = useRouter();
const { theme, setTheme } = useTheme();
const handleToggle = () => {
if (theme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
};
const [sidebar, setSidebar] = useState(false);
window.addEventListener("resize", () => setSidebar(false));
useEffect(() => {
@@ -33,10 +44,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 +61,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 +71,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]"
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 +94,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 +126,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="border border-solid border-sky-100 dark:border-neutral-700 w-full p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800">
<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>
+5 -2
View File
@@ -9,6 +9,7 @@ type Props = {
className?: string;
emptyImage?: boolean;
status?: Function;
priority?: boolean;
};
export default function ProfilePhoto({
@@ -16,6 +17,7 @@ export default function ProfilePhoto({
className,
emptyImage,
status,
priority,
}: Props) {
const [error, setError] = useState<boolean>(emptyImage || true);
@@ -33,7 +35,7 @@ export default function ProfilePhoto({
return error || !src ? (
<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>
@@ -43,7 +45,8 @@ export default function ProfilePhoto({
src={src}
height={112}
width={112}
className={`h-10 w-10 shadow rounded-full aspect-square border border-slate-200 ${className}`}
priority={priority}
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>
+2 -2
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>
@@ -44,7 +44,7 @@ export default function Search() {
router.push("/search/" + 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>
);
+199
View File
@@ -0,0 +1,199 @@
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 { 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-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} duration-100 py-2 px-2 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-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} duration-100 py-2 px-2 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-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} duration-100 py-2 px-2 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-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} duration-100 py-2 px-2 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_IS_ACTIVE ? (
<Link href="/settings/billing">
<div
className={`${
active === `/settings/billing`
? "bg-sky-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} duration-100 py-2 px-2 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://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 py-2 px-2 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-200 hover:dark:bg-neutral-700 duration-100 py-2 px-2 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-200 hover:dark:bg-neutral-700 duration-100 py-2 px-2 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-200 hover:dark:bg-neutral-700 duration-100 py-2 px-2 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>
);
}
+32 -26
View File
@@ -12,7 +12,6 @@ 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,53 +50,60 @@ 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"
? "bg-sky-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} 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`}
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-500`}
/>
<p className="text-sky-700 text-xs font-semibold">Dashboard</p>
<p className="text-black dark:text-white text-xs xl:text-sm font-semibold">
Dashboard
</p>
</Link>
<Link
href="/links"
className={`${
active === "/links"
? "bg-sky-200"
: "hover:bg-slate-200 bg-gray-100"
? "bg-sky-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} 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`}
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-500`}
/>
<p className="text-sky-700 text-xs font-semibold">
<span className="hidden xl:inline-block">All</span> Links
<p className="text-black dark:text-white text-xs xl:text-sm font-semibold">
Links
</p>
</Link>
<Link
href="/collections"
className={`${
active === "/collections" ? "bg-sky-200" : "hover:bg-slate-200"
active === "/collections"
? "bg-sky-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} 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`}
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-500`}
/>
<p className="text-sky-700 text-xs font-semibold">
<span className="hidden xl:inline-block">All</span> Collections
<p className="text-black dark:text-white text-xs xl:text-sm font-semibold">
Collections
</p>
</Link>
</div>
@@ -107,7 +113,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,8 +142,8 @@ export default function Sidebar({ className }: { className?: string }) {
<div
className={`${
active === `/collections/${e.id}`
? "bg-sky-200"
: "hover:bg-slate-200 bg-gray-100"
? "bg-sky-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
@@ -146,7 +152,7 @@ export default function Sidebar({ className }: { className?: string }) {
style={{ color: e.color }}
/>
<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>
@@ -157,7 +163,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 +176,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 +202,16 @@ export default function Sidebar({ className }: { className?: string }) {
<div
className={`${
active === `/tags/${e.id}`
? "bg-sky-200"
: "hover:bg-slate-200 bg-gray-100"
? "bg-sky-200 dark:bg-sky-800"
: "hover:bg-slate-200 hover:dark:bg-neutral-700"
} duration-100 py-1 px-2 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 +222,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-48"
>
<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)"
+2 -2
View File
@@ -27,8 +27,8 @@ export default function SubmitButton({
if (!loading) onClick();
}}
>
{icon && <FontAwesomeIcon icon={icon} className="h-5" />}
<p className="text-center w-full">{label}</p>
{icon && <FontAwesomeIcon icon={icon} className="h-5 select-none" />}
<p className="text-center w-full select-none">{label}</p>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
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;
+4 -1
View File
@@ -13,7 +13,10 @@ export default function useInitialData() {
const { setAccount } = useAccountStore();
useEffect(() => {
if (status === "authenticated") {
if (
status === "authenticated" &&
(!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE || data.user.isSubscriber)
) {
setCollections();
setTags();
// setLinks();
+7 -5
View File
@@ -12,12 +12,12 @@ export default function useLinks(
pinnedOnly,
collectionId,
tagId,
}: Omit<LinkRequestQuery, "cursor"> = { sort: 0 }
}: 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 = {
@@ -33,7 +33,7 @@ export default function useLinks(
const encodedData = encodeURIComponent(JSON.stringify(requestBody));
const response = await fetch(
`/api/routes/links?body=${encodeURIComponent(encodedData)}`
`/api/links?body=${encodeURIComponent(encodedData)}`
);
const data = await response.json();
@@ -48,6 +48,8 @@ export default function useLinks(
}, [router, sort, searchFilter]);
useEffect(() => {
if (hasReachedBottom) getLinks(false, links?.at(-1)?.id);
}, [hasReachedBottom]);
if (reachedBottom) getLinks(false, links?.at(-1)?.id);
setReachedBottom(false);
}, [reachedBottom]);
}
+49
View File
@@ -0,0 +1,49 @@
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">
{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 sm:w-[30rem] w-80 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>
);
}
+14 -27
View File
@@ -1,10 +1,6 @@
import Navbar from "@/components/Navbar";
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 ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
@@ -13,11 +9,6 @@ interface Props {
}
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 +17,20 @@ export default function MainLayout({ children }: Props) {
: (document.body.style.overflow = "auto");
}, [modal]);
if (status === "authenticated" && !redirect && routeExists)
return (
<>
<ModalManagement />
return (
<>
<ModalManagement />
<div className="flex">
<div className="hidden lg:block">
<Sidebar className="fixed top-0" />
</div>
<div className="w-full lg:ml-64 xl:ml-80">
<Navbar />
{children}
</div>
<div className="flex">
<div className="hidden lg:block">
<Sidebar className="fixed top-0" />
</div>
</>
);
else if ((status === "unauthenticated" && !redirect) || !routeExists)
return <>{children}</>;
else return <></>;
<div className="w-full flex flex-col h-screen lg:ml-64 xl:ml-80">
<Navbar />
{children}
</div>
</div>
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
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";
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);
window.addEventListener("resize", () => setSidebar(false));
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>
</>
);
}
+81 -60
View File
@@ -1,79 +1,100 @@
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";
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" });
if (user?.archiveAsWaybackMachine) sendToWayback(url);
await autoScroll(page);
if (user?.archiveAsPDF || user?.archiveAsScreenshot) {
const browser = await chromium.launch();
const context = await browser.newContext(devices["Desktop Chrome"]);
const page = await context.newPage();
const linkExists = await prisma.link.findUnique({
where: {
id: linkId,
},
});
try {
await page.goto(url, { waitUntil: "domcontentloaded" });
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,
await page.evaluate(
autoScroll,
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
);
const linkExists = await prisma.link.findUnique({
where: {
id: linkId,
},
});
createFile({
data: screenshot,
filePath: `archives/${collectionId}/${linkId}.png`,
});
if (linkExists) {
if (user.archiveAsScreenshot) {
const screenshot = await page.screenshot({
fullPage: true,
});
createFile({
data: pdf,
filePath: `archives/${collectionId}/${linkId}.pdf`,
});
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" },
});
createFile({
data: pdf,
filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`,
});
}
}
await browser.close();
} catch (err) {
console.log(err);
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(
`Auto scroll took too long (more than ${AUTOSCROLL_TIMEOUT} seconds).`
)
);
}, 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]);
};
+2 -7
View File
@@ -2,8 +2,7 @@ import Stripe from "stripe";
export default async function checkSubscription(
stripeSecretKey: string,
email: string,
priceId: string
email: string
) {
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2022-11-15",
@@ -33,11 +32,7 @@ export default async function checkSubscription(
new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000)
);
return (
subscription?.items?.data?.some(
(subscriptionItem) => subscriptionItem?.plan?.id === priceId
) && isNotCanceledOrHasTime
);
return subscription?.items?.data[0].plan && isNotCanceledOrHasTime;
}
);
@@ -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: {
+112 -117
View File
@@ -3,133 +3,130 @@ 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);
// Sorting logic
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
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.searchQuery) {
if (query.searchFilter?.name) {
searchConditions.push({
name: {
contains: query.searchQuery,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.url) {
searchConditions.push({
url: {
contains: query.searchQuery,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.description) {
searchConditions.push({
description: {
contains: query.searchQuery,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.tags) {
searchConditions.push({
tags: {
some: {
name: {
contains: query.searchQuery,
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.searchQuery ? "OR" : "AND"]: [
{
pinnedBy: query.pinnedOnly
? { some: { id: userId } }
: undefined,
},
...searchConditions,
],
},
],
},
],
},
@@ -141,9 +138,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 };
+6 -6
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,12 +20,12 @@ 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(
userId,
@@ -51,7 +51,7 @@ 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,
@@ -94,7 +94,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 };
}
+26 -12
View File
@@ -2,11 +2,13 @@ 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 updateLink(
link: LinkIncludingShortenedCollectionAndTags,
userId: number
) {
console.log(link);
if (!link || !link.collection.id)
return {
response: "Please choose a valid link and collection.",
@@ -31,11 +33,15 @@ export default async function updateLink(
const isCollectionOwner =
targetLink?.collection.ownerId === link.collection.ownerId &&
link.collection.ownerId === userId &&
targetLink?.collection.ownerId === userId;
link.collection.ownerId === userId;
const unauthorizedSwitchCollection =
!isCollectionOwner && targetLink?.collection.id !== link.collection.id;
console.log(isCollectionOwner);
// Makes sure collection members (non-owners) cannot move a link to/from a collection.
if (!isCollectionOwner)
if (unauthorizedSwitchCollection)
return {
response: "You can't move a link to/from a collection you don't own.",
status: 401,
@@ -53,15 +59,11 @@ export default async function updateLink(
data: {
name: link.name,
description: link.description,
collection:
targetLink?.collection.ownerId === link.collection.ownerId &&
link.collection.ownerId === userId
? {
connect: {
id: link.collection.id,
},
}
: undefined,
collection: {
connect: {
id: link.collection.id,
},
},
tags: {
set: [],
connectOrCreate: link.tags.map((tag) => ({
@@ -98,6 +100,18 @@ export default async function updateLink(
},
});
if (targetLink?.collection.id !== link.collection.id) {
await moveFile(
`archives/${targetLink?.collection.id}/${link.id}.pdf`,
`archives/${link.collection.id}/${link.id}.pdf`
);
await moveFile(
`archives/${targetLink?.collection.id}/${link.id}.png`,
`archives/${link.collection.id}/${link.id}.png`
);
}
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, image, ...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,100 @@
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
) {
try {
const dom = new JSDOM(rawData);
const document = dom.window.document;
const folders = document.querySelectorAll("H3");
// @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(),
},
});
}
}
} catch (err) {
console.log(err);
}
return { response: "Success.", status: 200 };
}
@@ -0,0 +1,91 @@
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);
console.log(typeof data);
// Import collections
try {
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,
},
},
},
})),
},
},
});
}
}
} catch (err) {
console.log(err);
}
return { response: "Success.", status: 200 };
}
+11 -2
View File
@@ -17,14 +17,23 @@ export default async function getUser({
id: params.lookupId,
username: params.lookupUsername?.toLowerCase(),
},
include: {
whitelistedUsers: {
select: {
username: true
}
}
}
});
if (!user) return { response: "User not found.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(usernames => usernames.username);
if (
!isSelf &&
user?.isPrivate &&
!user.whitelistedUsers.includes(username.toLowerCase())
!whitelistedUsernames.includes(username.toLowerCase())
) {
return { response: "This profile is private.", status: 401 };
}
@@ -33,7 +42,7 @@ export default async function getUser({
const data = isSelf
? // If user is requesting its own data
lessSensitiveInfo
{...lessSensitiveInfo, whitelistedUsers: whitelistedUsernames}
: {
// If user is requesting someone elses data
id: lessSensitiveInfo.id,
+79 -19
View File
@@ -3,7 +3,11 @@ 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";
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 updateUser(
user: AccountSettings,
@@ -14,9 +18,14 @@ export default async function updateUser(
isSubscriber: boolean;
}
) {
if (!user.username || !user.email)
if (emailEnabled && !user.email)
return {
response: "Username/Email invalid.",
response: "Email invalid.",
status: 400,
};
else if (!user.username)
return {
response: "Username invalid.",
status: 400,
};
@@ -32,14 +41,20 @@ export default async function updateUser(
const userIsTaken = await prisma.user.findFirst({
where: {
id: { not: sessionUser.id },
OR: [
{
username: user.username.toLowerCase(),
},
{
email: user.email.toLowerCase(),
},
],
OR: emailEnabled
? [
{
username: user.username.toLowerCase(),
},
{
email: user.email?.toLowerCase(),
},
]
: [
{
username: user.username.toLowerCase(),
},
],
},
});
@@ -58,6 +73,8 @@ export default async function updateUser(
try {
const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, "");
createFolder({ filePath: `uploads/avatar` });
await createFile({
filePath: `uploads/avatar/${sessionUser.id}.jpg`,
data: base64Data,
@@ -91,29 +108,72 @@ export default async function updateUser(
username: user.username.toLowerCase(),
email: user.email?.toLowerCase(),
isPrivate: user.isPrivate,
whitelistedUsers: user.whitelistedUsers,
archiveAsScreenshot: user.archiveAsScreenshot,
archiveAsPDF: user.archiveAsPDF,
archiveAsWaybackMachine: user.archiveAsWaybackMachine,
password:
user.newPassword && user.newPassword !== ""
? newHashedPassword
: undefined,
},
include: {
whitelistedUsers: true,
},
});
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const PRICE_ID = process.env.PRICE_ID;
const { whitelistedUsers, password, ...userInfo } = updatedUser;
if (STRIPE_SECRET_KEY && PRICE_ID)
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
const newWhitelistedUsernames: string[] = user.whitelistedUsers || [];
// Get the current whitelisted usernames
const currentWhitelistedUsernames: string[] = whitelistedUsers.map(
(user) => user.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: sessionUser.id,
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: sessionUser.id,
},
});
}
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (STRIPE_SECRET_KEY && emailEnabled && sessionUser.email !== user.email)
await updateCustomerEmail(
STRIPE_SECRET_KEY,
PRICE_ID,
sessionUser.email,
user.email
user.email as string
);
const { password, ...userInfo } = updatedUser;
const response: Omit<AccountSettings, "password"> = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
profilePic: `/api/avatar/${userInfo.id}?${Date.now()}`,
};
+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");
// });
-1
View File
@@ -1,5 +1,4 @@
import Stripe from "stripe";
import checkSubscription from "./checkSubscription";
export default async function paymentCheckout(
stripeSecretKey: string,
+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);
});
}
+37
View File
@@ -0,0 +1,37 @@
import fs from "fs";
import path from "path";
import s3Client from "./s3Client";
import removeFile from "./removeFile";
export default async function moveFile(from: string, to: string) {
if (s3Client) {
const Bucket = process.env.BUCKET_NAME;
const copyParams = {
Bucket: Bucket,
CopySource: `/${Bucket}/${from}`,
Key: to,
};
try {
s3Client.copyObject(copyParams, async (err: any) => {
if (err) {
console.error("Error copying the object:", err);
} else {
await removeFile({ filePath: from });
}
});
} catch (err) {
console.log("Error:", err);
}
} else {
const storagePath = process.env.STORAGE_FOLDER || "data";
const directory = (file: string) =>
path.join(process.cwd(), storagePath + "/" + file);
fs.rename(directory(from), directory(to), (err) => {
if (err) console.log("Error copying file:", err);
});
}
}
+87 -29
View File
@@ -1,14 +1,23 @@
import { GetObjectCommand, GetObjectCommandInput } from "@aws-sdk/client-s3";
import {
GetObjectCommand,
GetObjectCommandInput,
S3,
} from "@aws-sdk/client-s3";
import fs from "fs";
import path from "path";
import s3Client from "./s3Client";
import util from "util";
export default async function readFile({ filePath }: { filePath: string }) {
let contentType:
| "text/plain"
| "image/jpeg"
| "image/png"
| "application/pdf";
type ReturnContentTypes =
| "text/html"
| "image/jpeg"
| "image/png"
| "application/pdf";
export default async function readFile(filePath: string) {
const isRequestingAvatar = filePath.startsWith("uploads/avatar");
let contentType: ReturnContentTypes;
if (s3Client) {
const bucketParams: GetObjectCommandInput = {
@@ -17,26 +26,54 @@ export default async function readFile({ filePath }: { filePath: string }) {
};
try {
const response = await s3Client.send(new GetObjectCommand(bucketParams));
const data = await streamToBuffer(response.Body);
let returnObject:
| {
file: Buffer | string;
contentType: ReturnContentTypes;
status: number;
}
| undefined;
if (filePath.endsWith(".pdf")) {
contentType = "application/pdf";
} else if (filePath.endsWith(".png")) {
contentType = "image/png";
} else {
// if (filePath.endsWith(".jpg"))
contentType = "image/jpeg";
const headObjectAsync = util.promisify(
s3Client.headObject.bind(s3Client)
);
try {
await headObjectAsync(bucketParams);
} catch (err) {
contentType = "text/html";
returnObject = {
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate,
contentType,
status: isRequestingAvatar ? 200 : 400,
};
}
return { file: data, contentType };
if (!returnObject) {
const response = await (s3Client as S3).send(
new GetObjectCommand(bucketParams)
);
const data = await streamToBuffer(response.Body);
if (filePath.endsWith(".pdf")) {
contentType = "application/pdf";
} else if (filePath.endsWith(".png")) {
contentType = "image/png";
} else {
// if (filePath.endsWith(".jpg"))
contentType = "image/jpeg";
}
returnObject = { file: data as Buffer, contentType, status: 200 };
}
return returnObject;
} catch (err) {
console.log("Error", err);
contentType = "text/plain";
console.log("Error:", err);
contentType = "text/html";
return {
file: "File not found, it's possible that the file you're looking for either doesn't exist or hasn't been created yet.",
file: "An internal occurred, please contact support.",
contentType,
};
}
@@ -44,13 +81,7 @@ export default async function readFile({ filePath }: { filePath: string }) {
const storagePath = process.env.STORAGE_FOLDER || "data";
const creationPath = path.join(process.cwd(), storagePath + "/" + filePath);
const file = fs.existsSync(creationPath)
? fs.readFileSync(creationPath)
: "File not found, it's possible that the file you're looking for either doesn't exist or hasn't been created yet.";
if (file.toString().startsWith("File not found")) {
contentType = "text/plain";
} else if (filePath.endsWith(".pdf")) {
if (filePath.endsWith(".pdf")) {
contentType = "application/pdf";
} else if (filePath.endsWith(".png")) {
contentType = "image/png";
@@ -59,7 +90,16 @@ export default async function readFile({ filePath }: { filePath: string }) {
contentType = "image/jpeg";
}
return { file, contentType };
if (!fs.existsSync(creationPath))
return {
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate,
contentType: "text/html",
status: isRequestingAvatar ? 200 : 400,
};
else {
const file = fs.readFileSync(creationPath);
return { file, contentType, status: 200 };
}
}
}
@@ -72,3 +112,21 @@ const streamToBuffer = (stream: any) => {
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
};
const fileNotFoundTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File not found</title>
</head>
<body style="margin-left: auto; margin-right: auto; max-width: 500px; padding: 1rem; font-family: sans-serif; background-color: rgb(251, 251, 251);">
<h1>File not found</h1>
<h2>It is possible that the file you're looking for either doesn't exist or hasn't been created yet.</h2>
<h3>Some possible reasons are:</h3>
<ul>
<li>You are trying to access a file too early, before it has been fully archived. If that's the case, refreshing the page might resolve the issue.</li>
<li>The file doesn't exist either because it encountered an error while being archived, or it simply doesn't exist.</li>
</ul>
</body>
</html>`;
+1 -6
View File
@@ -2,7 +2,6 @@ import Stripe from "stripe";
export default async function updateCustomerEmail(
stripeSecretKey: string,
priceId: string,
email: string,
newEmail: string
) {
@@ -30,11 +29,7 @@ export default async function updateCustomerEmail(
new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000)
);
return (
subscription?.items?.data?.some(
(subscriptionItem) => subscriptionItem?.plan?.id === priceId
) && isNotCanceledOrHasTime
);
return subscription?.items?.data[0].plan && isNotCanceledOrHasTime;
}
);
+10 -1
View File
@@ -1,4 +1,13 @@
const avatarCache = new Map();
export default async function avatarExists(fileUrl: string): Promise<boolean> {
if (avatarCache.has(fileUrl)) {
return avatarCache.get(fileUrl);
}
const response = await fetch(fileUrl, { method: "HEAD" });
return !(response.headers.get("content-type") === "text/plain");
const exists = !(response.headers.get("content-type") === "text/html");
avatarCache.set(fileUrl, exists);
return exists;
}
+1 -1
View File
@@ -17,7 +17,7 @@ const getPublicCollectionData = async (
const encodedData = encodeURIComponent(JSON.stringify(requestBody));
const res = await fetch(
"/api/public/routes/collections?body=" + encodeURIComponent(encodedData)
"/api/public/collections?body=" + encodeURIComponent(encodedData)
);
const data = await res.json();
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function getPublicUserData({
id?: number;
}) {
const response = await fetch(
`/api/routes/users?id=${id}&${
`/api/users?id=${id}&${
username ? `username=${username?.toLowerCase()}` : undefined
}`
);
+4
View File
@@ -0,0 +1,4 @@
export default function htmlDecode(input: string) {
var doc = new DOMParser().parseFromString(input, "text/html");
return doc.documentElement.textContent;
}
+12 -5
View File
@@ -14,8 +14,9 @@
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.1",
"@aws-sdk/client-s3": "^3.363.0",
"@aws-sdk/client-s3": "^3.379.1",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
@@ -28,13 +29,18 @@
"@types/nodemailer": "^6.4.8",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.7",
"axios": "^1.5.1",
"bcrypt": "^5.1.0",
"colorthief": "^2.4.0",
"crypto-js": "^4.1.1",
"eslint": "8.44.0",
"csstype": "^3.1.2",
"eslint": "8.46.0",
"eslint-config-next": "13.4.9",
"next": "13.1.6",
"framer-motion": "^10.16.4",
"jsdom": "^22.1.0",
"next": "13.4.12",
"next-auth": "^4.22.1",
"next-themes": "^0.2.1",
"nodemailer": "^6.9.3",
"playwright": "^1.35.1",
"react": "18.2.0",
@@ -42,7 +48,7 @@
"react-dom": "18.2.0",
"react-hot-toast": "^2.4.1",
"react-image-file-resizer": "^0.4.8",
"react-select": "^5.7.3",
"react-select": "^5.7.4",
"sharp": "^0.32.1",
"stripe": "^12.13.0",
"typescript": "4.9.4",
@@ -51,9 +57,10 @@
"devDependencies": {
"@playwright/test": "^1.35.1",
"@types/bcrypt": "^5.0.0",
"@types/jsdom": "^21.1.3",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.26",
"prisma": "^4.16.2",
"prisma": "^5.1.0",
"tailwindcss": "^3.3.3"
}
}
+20 -7
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect } from "react";
import "@/styles/globals.css";
import { SessionProvider } from "next-auth/react";
import type { AppProps } from "next/app";
@@ -6,6 +6,7 @@ import Head from "next/head";
import AuthRedirect from "@/layouts/AuthRedirect";
import { Toaster } from "react-hot-toast";
import { Session } from "next-auth";
import { ThemeProvider } from "next-themes";
export default function App({
Component,
@@ -13,6 +14,13 @@ export default function App({
}: AppProps<{
session: Session;
}>) {
const defaultTheme: "light" | "dark" = "dark";
useEffect(() => {
if (!localStorage.getItem("theme"))
localStorage.setItem("theme", defaultTheme);
}, []);
return (
<SessionProvider session={pageProps.session}>
<Head>
@@ -37,13 +45,18 @@ export default function App({
/>
<link rel="manifest" href="/site.webmanifest" />
</Head>
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{ className: "border border-sky-100" }}
/>
<AuthRedirect>
<Component {...pageProps} />
<ThemeProvider attribute="class">
<Toaster
position="top-center"
reverseOrder={false}
toastOptions={{
className:
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white",
}}
/>
<Component {...pageProps} />
</ThemeProvider>
</AuthRedirect>
</SessionProvider>
);
+4 -4
View File
@@ -31,10 +31,10 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.status(401)
.json({ response: "You don't have access to this collection." });
const { file, contentType } = await readFile({
filePath: `archives/${collectionId}/${linkId}`,
});
res.setHeader("Content-Type", contentType).status(200);
const { file, contentType, status } = await readFile(
`archives/${collectionId}/${linkId}`
);
res.setHeader("Content-Type", contentType).status(status as number);
return res.send(file);
}
+1 -4
View File
@@ -91,7 +91,6 @@ export const authOptions: AuthOptions = {
// Using the `...rest` parameter to be able to narrow down the type based on `trigger`
async jwt({ token, trigger, session, user }) {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const PRICE_ID = process.env.PRICE_ID;
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
@@ -108,13 +107,11 @@ export const authOptions: AuthOptions = {
if (
STRIPE_SECRET_KEY &&
PRICE_ID &&
(trigger || subscriptionIsTimesUp || !token.isSubscriber)
) {
const subscription = await checkSubscription(
STRIPE_SECRET_KEY,
token.email as string,
PRICE_ID
token.email as string
);
if (subscription.subscriptionCanceledAt) {
+4
View File
@@ -20,6 +20,10 @@ export default async function Index(
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
+18 -13
View File
@@ -13,7 +13,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (!userId || !username)
return res
.setHeader("Content-Type", "text/plain")
.setHeader("Content-Type", "text/html")
.status(401)
.send("You must be logged in.");
else if (session?.user?.isSubscriber === false)
@@ -24,7 +24,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (!queryId)
return res
.setHeader("Content-Type", "text/plain")
.setHeader("Content-Type", "text/html")
.status(401)
.send("Invalid parameters.");
@@ -33,23 +33,28 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
where: {
id: queryId,
},
include: {
whitelistedUsers: true,
},
});
if (
targetUser?.isPrivate &&
!targetUser.whitelistedUsers.includes(username)
) {
const whitelistedUsernames = targetUser?.whitelistedUsers.map(
(whitelistedUsername) => whitelistedUsername.username
);
if (targetUser?.isPrivate && !whitelistedUsernames?.includes(username)) {
return res
.setHeader("Content-Type", "text/plain")
.setHeader("Content-Type", "text/html")
.send("This profile is private.");
}
}
const { file, contentType } = await readFile({
filePath: `uploads/avatar/${queryId}.jpg`,
});
const { file, contentType, status } = await readFile(
`uploads/avatar/${queryId}.jpg`
);
res.setHeader("Content-Type", contentType);
return res.send(file);
return res
.setHeader("Content-Type", contentType)
.status(status as number)
.send(file);
}
+41
View File
@@ -0,0 +1,41 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import exportData from "@/lib/api/controllers/migration/exportData";
import importFromHTMLFile from "@/lib/api/controllers/migration/importFromHTMLFile";
import importFromLinkwarden from "@/lib/api/controllers/migration/importFromLinkwarden";
import { MigrationFormat, MigrationRequest } from "@/types/global";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "GET") {
const data = await exportData(session.user.id);
if (data.status === 200)
return res
.setHeader("Content-Type", "application/json")
.setHeader("Content-Disposition", "attachment; filename=backup.json")
.status(data.status)
.json(data.response);
} else if (req.method === "POST") {
const request: MigrationRequest = JSON.parse(req.body);
let data;
if (request.format === MigrationFormat.htmlFile)
data = await importFromHTMLFile(session.user.id, request.data);
if (request.format === MigrationFormat.linkwarden)
data = await importFromLinkwarden(session.user.id, request.data);
if (data) return res.status(data.status).json({ response: data.response });
}
}
+11 -2
View File
@@ -2,18 +2,27 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import paymentCheckout from "@/lib/api/paymentCheckout";
import { Plan } from "@/types/global";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const PRICE_ID = process.env.PRICE_ID;
const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID;
const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID;
const session = await getServerSession(req, res, authOptions);
if (!session?.user?.id)
return res.status(401).json({ response: "You must be logged in." });
else if (!STRIPE_SECRET_KEY || !PRICE_ID) {
else if (!STRIPE_SECRET_KEY || !MONTHLY_PRICE_ID || !YEARLY_PRICE_ID) {
return res.status(400).json({ response: "Payment is disabled." });
}
let PRICE_ID = MONTHLY_PRICE_ID;
if ((Number(req.query.plan) as unknown as Plan) === Plan.monthly)
PRICE_ID = MONTHLY_PRICE_ID;
else if ((Number(req.query.plan) as unknown as Plan) === Plan.yearly)
PRICE_ID = YEARLY_PRICE_ID;
if (req.method === "GET") {
const users = await paymentCheckout(
STRIPE_SECRET_KEY,
+17 -29
View File
@@ -1,13 +1,13 @@
import SubmitButton from "@/components/SubmitButton";
import { signOut } from "next-auth/react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import useAccountStore from "@/store/account";
import CenteredForm from "@/layouts/CenteredForm";
import TextInput from "@/components/TextInput";
export default function Subscribe() {
export default function ChooseUsername() {
const [submitLoader, setSubmitLoader] = useState(false);
const [inputedUsername, setInputedUsername] = useState("");
@@ -15,10 +15,6 @@ export default function Subscribe() {
const { updateAccount, account } = useAccountStore();
useEffect(() => {
console.log(data?.user);
}, [status]);
async function submitUsername() {
setSubmitLoader(true);
@@ -41,36 +37,31 @@ export default function Subscribe() {
}
return (
<>
<Image
src="/linkwarden.png"
width={518}
height={145}
alt="Linkwarden"
className="h-12 w-fit mx-auto mt-10"
/>
<div className="p-2 mt-10 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
<p className="text-xl text-sky-700 w-fit font-bold">
<CenteredForm>
<div className="p-4 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:border-neutral-700 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100">
<p className="text-2xl text-center text-black dark:text-white font-bold">
Choose a Username (Last step)
</p>
<div>
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Username
</p>
<input
type="text"
<TextInput
placeholder="john"
value={inputedUsername}
className="bg-white"
onChange={(e) => setInputedUsername(e.target.value)}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
<div>
<p className="text-md text-gray-500 mt-1">
<p className="text-md text-gray-500 dark:text-gray-400 mt-1">
Feel free to reach out to us at{" "}
<a className="font-semibold" href="mailto:support@linkwarden.app">
<a
className="font-semibold underline"
href="mailto:support@linkwarden.app"
>
support@linkwarden.app
</a>{" "}
in case of any issues.
@@ -86,14 +77,11 @@ export default function Subscribe() {
<div
onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-gray-500 font-semibold "
className="w-fit mx-auto cursor-pointer text-gray-500 dark:text-gray-400 font-semibold "
>
Sign Out
</div>
</div>
<p className="text-center text-xs text-gray-500 my-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</>
</CenteredForm>
);
}
+27 -37
View File
@@ -19,12 +19,15 @@ import useModalStore from "@/store/modals";
import useLinks from "@/hooks/useLinks";
import usePermissions from "@/hooks/usePermissions";
import NoLinksFound from "@/components/NoLinksFound";
import { useTheme } from "next-themes";
export default function Index() {
const { setModal } = useModalStore();
const router = useRouter();
const { theme } = useTheme();
const { links } = useLinkStore();
const { collections } = useCollectionStore();
@@ -50,8 +53,17 @@ export default function Index() {
return (
<MainLayout>
<div className="p-5 flex flex-col gap-5 w-full">
<div className="bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% rounded-2xl shadow min-h-[10rem] p-5 flex gap-5 flex-col justify-between">
<div className="flex flex-col sm:flex-row gap-3 justify-between items-center sm:items-start">
<div
style={{
backgroundImage: `linear-gradient(-45deg, ${
activeCollection?.color
}30 10%, ${theme === "dark" ? "#262626" : "#f3f4f6"} 50%, ${
theme === "dark" ? "#262626" : "#f9fafb"
} 100%)`,
}}
className="border border-solid border-sky-100 dark:border-neutral-700 rounded-2xl shadow min-h-[10rem] p-5 flex gap-5 flex-col justify-between"
>
<div className="flex flex-col sm:flex-row gap-3 justify-between sm:items-start">
{activeCollection && (
<div className="flex gap-3 items-center">
<div className="flex gap-2">
@@ -60,7 +72,7 @@ export default function Index() {
style={{ color: activeCollection?.color }}
className="sm:w-8 sm:h-8 w-6 h-6 mt-3 drop-shadow"
/>
<p className="sm:text-4xl text-3xl capitalize text-sky-700 font-bold w-full py-1 break-words hyphens-auto">
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white w-full py-1 break-words hyphens-auto">
{activeCollection?.name}
</p>
</div>
@@ -84,15 +96,8 @@ export default function Index() {
defaultIndex: permissions === true ? 1 : 0,
})
}
className="flex justify-center sm:justify-end items-center w-fit mx-auto sm:mr-0 sm:ml-auto group cursor-pointer"
className="hover:opacity-80 duration-100 flex justify-center sm:justify-end items-center w-fit sm:mr-0 sm:ml-auto cursor-pointer"
>
<div
className={`bg-sky-700 p-2 leading-3 select-none group-hover:bg-sky-600 duration-100 text-white rounded-full text-xs ${
activeCollection.members[0] && "mr-1"
}`}
>
{permissions === true ? "Manage" : "View"} Team
</div>
{activeCollection?.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
@@ -100,14 +105,14 @@ export default function Index() {
<ProfilePhoto
key={i}
src={`/api/avatar/${e.userId}?${Date.now()}`}
className="-mr-3 duration-100 border-[3px]"
className="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{activeCollection?.members.length &&
activeCollection.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">
<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">
+{activeCollection?.members?.length - 4}
</div>
) : null}
@@ -116,19 +121,19 @@ export default function Index() {
) : null}
</div>
<div className="text-gray-600 flex justify-between items-end gap-5">
<div className="text-black dark:text-white flex justify-between items-end gap-5">
<p>{activeCollection?.description}</p>
<div className="flex items-center gap-2">
<div className="relative">
<div
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 duration-100 p-1"
className="inline-flex rounded-md cursor-pointer hover:bg-black hover:dark:bg-white hover:bg-opacity-10 hover:dark:bg-opacity-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
@@ -144,31 +149,18 @@ export default function Index() {
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id="expand-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 duration-100 p-1"
className="inline-flex rounded-md cursor-pointer hover:bg-black hover:dark:bg-white hover:bg-opacity-10 hover:dark:bg-opacity-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
id="expand-dropdown"
title="More"
className="w-5 h-5 text-gray-500"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{expandDropdown ? (
<Dropdown
items={[
permissions === true || permissions?.canCreate
? {
name: "Add Link Here",
onClick: () => {
setModal({
modal: "LINK",
state: true,
method: "CREATE",
});
setExpandDropdown(false);
},
}
: undefined,
permissions === true
? {
name: "Edit Collection Info",
@@ -235,13 +227,11 @@ export default function Index() {
</div>
</div>
</div>
{links[0] ? (
{links.some((e) => e.collectionId === Number(router.query.id)) ? (
<div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5">
{links
.filter((e) => e.collectionId === Number(router.query.id))
.map((e, i) => {
return <LinkCard key={i} link={e} count={i} />;
})}
{links.map((e, i) => {
return <LinkCard key={i} link={e} count={i} />;
})}
</div>
) : (
<NoLinksFound />
+12 -9
View File
@@ -15,8 +15,11 @@ import useModalStore from "@/store/modals";
import SortDropdown from "@/components/SortDropdown";
import { Sort } from "@/types/global";
import useSort from "@/hooks/useSort";
import { useTheme } from "next-themes";
export default function Collections() {
const { theme } = useTheme();
const { collections } = useCollectionStore();
const [expandDropdown, setExpandDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false);
@@ -37,9 +40,9 @@ export default function Collections() {
<div className="flex gap-2">
<FontAwesomeIcon
icon={faFolder}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 drop-shadow"
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow"
/>
<p className="sm:text-4xl text-3xl capitalize text-sky-700 font-bold">
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white">
All Collections
</p>
</div>
@@ -47,12 +50,12 @@ export default function Collections() {
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id="expand-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 duration-100 p-1"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
id="expand-dropdown"
className="w-5 h-5 text-gray-500"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
@@ -86,12 +89,12 @@ export default function Collections() {
<div
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 duration-100 p-1"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
@@ -111,7 +114,7 @@ export default function Collections() {
})}
<div
className="p-5 self-stretch bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% min-h-[12rem] rounded-2xl cursor-pointer shadow duration-100 hover:shadow-none flex flex-col gap-4 justify-center items-center group"
className="p-5 bg-gray-50 dark:bg-neutral-800 self-stretch border border-solid border-sky-100 dark:border-neutral-700 min-h-[12rem] rounded-2xl cursor-pointer shadow duration-100 hover:shadow-none flex flex-col gap-4 justify-center items-center group"
onClick={() => {
setModal({
modal: "COLLECTION",
@@ -120,12 +123,12 @@ export default function Collections() {
});
}}
>
<p className="text-sky-900 group-hover:opacity-0 duration-100">
<p className="text-black dark:text-white group-hover:opacity-0 duration-100">
New Collection
</p>
<FontAwesomeIcon
icon={faPlus}
className="w-8 h-8 text-sky-500 group-hover:w-12 group-hover:h-12 group-hover:-mt-10 duration-100"
className="w-8 h-8 text-sky-500 dark:text-sky-500 group-hover:w-12 group-hover:h-12 group-hover:-mt-10 duration-100"
/>
</div>
</div>
+19 -16
View File
@@ -1,25 +1,28 @@
import { signIn } from "next-auth/react";
import CenteredForm from "@/layouts/CenteredForm";
import Link from "next/link";
import React from "react";
export default function EmailConfirmaion() {
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="mx-auto p-3 text-center rounded-xl border border-sky-100 shadow-lg bg-gray-100 text-sky-800">
<p className="text-center text-2xl mb-2">Please check your email</p>
<CenteredForm>
<div className="p-4 sm:w-[30rem] w-80 rounded-2xl shadow-md m-auto border border-sky-100 dark:border-neutral-700 bg-slate-50 text-black dark:text-white dark:bg-neutral-800">
<p className="text-center text-xl font-bold mb-2">
Please check your Email
</p>
<p>A sign in link has been sent to your email address.</p>
<p>You can safely close this page.</p>
{/* <div
onClick={() =>
signIn("email", {
email: email,
redirect: false,
})
}
className="mx-auto font-semibold mt-2 cursor-pointer w-fit"
>
Resend?
</div> */}
<hr className="my-5 dark:border-neutral-700" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Didn&apos;t find the email in your inbox? Check your spam folder or
visit the{" "}
<Link href="/forgot" className="font-bold underline">
Password Recovery
</Link>{" "}
page to resend the sign-in link by entering your email.
</p>
</div>
</div>
</CenteredForm>
);
}
+68 -175
View File
@@ -2,15 +2,13 @@ import useCollectionStore from "@/store/collections";
import {
faChartSimple,
faChevronDown,
faThumbTack,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links";
import useTagStore from "@/store/tags";
import LinkCard from "@/components/LinkCard";
import Link from "next/link";
import CollectionCard from "@/components/CollectionCard";
import { Disclosure, Transition } from "@headlessui/react";
import { useEffect, useState } from "react";
import useLinks from "@/hooks/useLinks";
@@ -21,20 +19,6 @@ export default function Dashboard() {
const [numberOfLinks, setNumberOfLinks] = useState(0);
const [tagPinDisclosure, setTagPinDisclosure] = useState<boolean>(() => {
const storedValue =
typeof window !== "undefined" && localStorage.getItem("tagPinDisclosure");
return storedValue ? storedValue === "true" : true;
});
const [collectionPinDisclosure, setCollectionPinDisclosure] =
useState<boolean>(() => {
const storedValue =
typeof window !== "undefined" &&
localStorage.getItem("collectionPinDisclosure");
return storedValue ? storedValue === "true" : true;
});
const [linkPinDisclosure, setLinkPinDisclosure] = useState<boolean>(() => {
const storedValue =
typeof window !== "undefined" &&
@@ -54,20 +38,6 @@ export default function Dashboard() {
);
}, [collections]);
useEffect(() => {
localStorage.setItem(
"tagPinDisclosure",
tagPinDisclosure ? "true" : "false"
);
}, [tagPinDisclosure]);
useEffect(() => {
localStorage.setItem(
"collectionPinDisclosure",
collectionPinDisclosure ? "true" : "false"
);
}, [collectionPinDisclosure]);
useEffect(() => {
localStorage.setItem(
"linkPinDisclosure",
@@ -76,183 +46,106 @@ export default function Dashboard() {
}, [linkPinDisclosure]);
return (
// ml-80
<MainLayout>
<div className="p-5">
<div className="flex gap-3 items-center mb-5">
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
<div className="flex gap-3 items-center">
<div className="flex gap-2">
<FontAwesomeIcon
icon={faChartSimple}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 drop-shadow"
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow"
/>
<p className="sm:text-4xl text-3xl capitalize text-sky-700 font-bold">
<p className="sm:text-4xl text-3xl text-black dark:text-white">
Dashboard
</p>
</div>
</div>
<br />
<div className="flex flex-col md:flex-row md:items-center justify-evenly gap-2 mb-10">
<div className="flex items-baseline gap-2">
<p className="font-bold text-6xl text-sky-700">{numberOfLinks}</p>
<p className="text-sky-900 text-xl">
<div className="flex flex-col md:flex-row md:items-center gap-5">
<div className="sky-shadow flex flex-col justify-center items-center gap-2 md:w-full rounded-2xl p-10 border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800">
<p className="font-bold text-6xl text-sky-500 dark:text-sky-500">
{numberOfLinks}
</p>
<p className="text-black dark:text-white text-xl">
{numberOfLinks === 1 ? "Link" : "Links"}
</p>
</div>
<div className="flex items-baseline gap-2">
<p className="font-bold text-6xl text-sky-700">
<div className="sky-shadow flex flex-col justify-center items-center gap-2 md:w-full rounded-2xl p-10 border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800">
<p className="font-bold text-6xl text-sky-500 dark:text-sky-500">
{collections.length}
</p>
<p className="text-sky-900 text-xl">
<p className="text-black dark:text-white text-xl">
{collections.length === 1 ? "Collection" : "Collections"}
</p>
</div>
<div className="flex items-baseline gap-2">
<p className="font-bold text-6xl text-sky-700">{tags.length}</p>
<p className="text-sky-900 text-xl">
<div className="sky-shadow flex flex-col justify-center items-center gap-2 md:w-full rounded-2xl p-10 border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800">
<p className="font-bold text-6xl text-sky-500 dark:text-sky-500">
{tags.length}
</p>
<p className="text-black dark:text-white text-xl">
{tags.length === 1 ? "Tag" : "Tags"}
</p>
</div>
</div>
{/* <hr className="my-5 border-sky-100" /> */}
<br />
<div className="flex flex-col 2xl:flex-row items-start justify-evenly 2xl:gap-2">
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<FontAwesomeIcon
icon={faThumbTack}
className="w-5 h-5 text-sky-500 dark:text-sky-500 drop-shadow"
/>
<p className="text-2xl text-black dark:text-white">Pinned Links</p>
</div>
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<Disclosure defaultOpen={linkPinDisclosure}>
<div className="flex flex-col gap-5 p-2 w-full mx-auto md:w-2/3">
<Disclosure.Button
onClick={() => {
setLinkPinDisclosure(!linkPinDisclosure);
}}
className="flex justify-between gap-2 items-baseline shadow active:shadow-inner duration-100 py-2 px-4 rounded-full"
>
<p className="text-sky-700 text-xl">Pinned Links</p>
<button
className="text-black dark:text-white flex items-center gap-2 cursor-pointer"
onClick={() => setLinkPinDisclosure(!linkPinDisclosure)}
>
{linkPinDisclosure ? "Show Less" : "Show More"}
<FontAwesomeIcon
icon={faChevronDown}
className={`w-4 h-4 text-black dark:text-white ${
linkPinDisclosure ? "rotate-reverse" : "rotate"
}`}
/>
</button>
) : undefined}
</div>
<div className="text-sky-700 flex items-center gap-2">
{linkPinDisclosure ? "Hide" : "Show"}
<FontAwesomeIcon
icon={faChevronDown}
className={`w-4 h-4 text-sky-300 ${
linkPinDisclosure ? "rotate-reverse" : "rotate"
}`}
/>
</div>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="grid grid-cols-1 xl:grid-cols-2 gap-5 w-full">
{links
.filter((e) => e.pinnedBy && e.pinnedBy[0])
.map((e, i) => (
<LinkCard key={i} link={e} count={i} />
))}
</Disclosure.Panel>
</Transition>
<div
style={{ flex: "1 1 auto" }}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
>
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full">
<div
className={`grid overflow-hidden 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full ${
linkPinDisclosure ? "h-full" : "h-44"
}`}
>
{links
.filter((e) => e.pinnedBy && e.pinnedBy[0])
.map((e, i) => (
<LinkCard key={i} link={e} count={i} />
))}
</div>
</Disclosure>
</div>
) : (
<div className="border border-solid border-sky-100 w-full mx-auto md:w-2/3 p-10 rounded-2xl">
<p className="text-center text-2xl text-sky-700">
No Pinned Links
<div
style={{ flex: "1 1 auto" }}
className="sky-shadow flex flex-col justify-center h-full border border-solid border-sky-100 dark:border-neutral-700 w-full mx-auto p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800"
>
<p className="text-center text-2xl text-black dark:text-white">
Pin Your Favorite Links Here!
</p>
<p className="text-center text-sky-900 text-sm">
You can Pin Links by clicking on the three dots on each Link and
clicking &quot;Pin to Dashboard.&quot;
<p className="text-center mx-auto max-w-96 w-fit text-gray-500 dark:text-gray-300 text-sm mt-2">
You can Pin your favorite Links by clicking on the three dots on
each Link and clicking{" "}
<span className="font-semibold">Pin to Dashboard</span>.
</p>
</div>
)}
{/* <Disclosure defaultOpen={collectionPinDisclosure}>
<div className="flex flex-col gap-5 p-2 w-full">
<Disclosure.Button
onClick={() => {
setCollectionPinDisclosure(!collectionPinDisclosure);
}}
className="flex justify-between gap-2 items-baseline shadow active:shadow-inner duration-100 py-2 px-4 rounded-full"
>
<p className="text-sky-700 text-xl">Pinned Collections</p>
<div className="text-sky-700 flex items-center gap-2">
{collectionPinDisclosure ? "Hide" : "Show"}
<FontAwesomeIcon
icon={faChevronDown}
className={`w-4 h-4 text-sky-300 ${
collectionPinDisclosure ? "rotate-reverse" : "rotate"
}`}
/>
</div>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex flex-col gap-5 w-full">
{collections.slice(0, 5).map((e, i) => (
<CollectionCard key={i} collection={e} />
))}
</Disclosure.Panel>
</Transition>
</div>
</Disclosure> */}
{/* <Disclosure defaultOpen={tagPinDisclosure}>
<div className="flex flex-col gap-5 p-2 w-full">
<Disclosure.Button
onClick={() => {
setTagPinDisclosure(!tagPinDisclosure);
}}
className="flex justify-between gap-2 items-baseline shadow active:shadow-inner duration-100 py-2 px-4 rounded-full"
>
<p className="text-sky-700 text-xl">Pinned Tags</p>
<div className="text-sky-700 flex items-center gap-2">
{tagPinDisclosure ? "Hide" : "Show"}
<FontAwesomeIcon
icon={faChevronDown}
className={`w-4 h-4 text-sky-300 ${
tagPinDisclosure ? "rotate-reverse" : "rotate"
}`}
/>
</div>
</Disclosure.Button>
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0 -translate-y-3"
enterTo="transform opacity-100 translate-y-0"
leave="transition duration-100 ease-out"
leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3"
>
<Disclosure.Panel className="flex gap-2 flex-wrap">
{tags.slice(0, 19).map((e, i) => (
<Link
href={`/tags/${e.id}`}
key={i}
className="px-2 py-1 bg-sky-200 rounded-full hover:opacity-60 duration-100 text-sky-700"
>
{e.name}
</Link>
))}
</Disclosure.Panel>
</Transition>
</div>
</Disclosure> */}
</div>
</div>
</MainLayout>
+27 -25
View File
@@ -1,6 +1,7 @@
import SubmitButton from "@/components/SubmitButton";
import TextInput from "@/components/TextInput";
import CenteredForm from "@/layouts/CenteredForm";
import { signIn } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -38,31 +39,32 @@ export default function Forgot() {
}
return (
<>
<Image
src="/linkwarden.png"
width={518}
height={145}
alt="Linkwarden"
className="h-12 w-fit mx-auto mt-10"
/>
<div className="p-2 mt-10 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
<p className="text-xl text-sky-700 w-fit font-bold">Fogot Password?</p>
<p className="text-md text-gray-500 mt-1">
Enter your Email so we can send you a link to recover your account.
</p>
<p className="text-md text-gray-500 mt-1">
Make sure to change your password in the profile settings afterwards.
<CenteredForm>
<div className="p-4 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:border-neutral-700 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100">
<p className="text-2xl text-center text-black dark:text-white font-bold">
Password Recovery
</p>
<div>
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">Email</p>
<p className="text-md text-black dark:text-white">
Enter your Email so we can send you a link to recover your account.
Make sure to change your password in the profile settings
afterwards.
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
You wont get logged in if you haven&apos;t created an account yet.
</p>
</div>
<div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Email
</p>
<input
type="text"
<TextInput
type="email"
placeholder="johnny@example.com"
value={form.email}
className="bg-white"
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
@@ -73,14 +75,14 @@ export default function Forgot() {
loading={submitLoader}
/>
<div className="flex items-baseline gap-1 justify-center">
<Link href={"/login"} className="block text-sky-700 font-bold">
<Link
href={"/login"}
className="block text-black dark:text-white font-bold"
>
Go back
</Link>
</div>
</div>
<p className="text-center text-xs text-gray-500 my-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</>
</CenteredForm>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useRouter } from "next/router";
import { useEffect } from "react";
export default function Home() {
export default function Index() {
const router = useRouter();
useEffect(() => {
+4 -4
View File
@@ -24,9 +24,9 @@ export default function Links() {
<div className="flex gap-2">
<FontAwesomeIcon
icon={faLink}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 drop-shadow"
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow"
/>
<p className="sm:text-4xl text-3xl capitalize text-sky-700 font-bold">
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white">
All Links
</p>
</div>
@@ -35,12 +35,12 @@ export default function Links() {
<div
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 duration-100 p-1"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
+30 -34
View File
@@ -1,6 +1,7 @@
import SubmitButton from "@/components/SubmitButton";
import TextInput from "@/components/TextInput";
import CenteredForm from "@/layouts/CenteredForm";
import { signIn } from "next-auth/react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { toast } from "react-hot-toast";
@@ -45,51 +46,44 @@ export default function Login() {
}
return (
<>
<Image
src="/linkwarden.png"
width={518}
height={145}
alt="Linkwarden"
className="h-12 w-fit mx-auto mt-10"
/>
<p className="text-xl font-semibold text-sky-700 px-2 text-center">
Sign in to your account
</p>
<div className="p-2 my-10 mx-auto flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
<p className="text-xl text-sky-700 w-fit font-bold">
<CenteredForm text="Sign in to your account">
<div className="p-4 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700">
<p className="text-2xl text-black dark:text-white text-center font-bold">
Enter your credentials
</p>
<div>
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Username
{emailEnabled ? "/Email" : undefined}
{emailEnabled ? " or Email" : undefined}
</p>
<input
type="text"
<TextInput
placeholder="johnny"
value={form.username}
className="bg-white"
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
<div>
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Password
</p>
<input
<TextInput
type="password"
placeholder="••••••••••••••"
value={form.password}
className="bg-white"
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
{emailEnabled && (
<div className="w-fit ml-auto mt-1">
<Link href={"/forgot"} className="text-gray-500 font-semibold">
<Link
href={"/forgot"}
className="text-gray-500 dark:text-gray-400 font-semibold"
>
Forgot Password?
</Link>
</div>
@@ -99,19 +93,21 @@ export default function Login() {
<SubmitButton
onClick={loginUser}
label="Login"
className="mt-2 w-full text-center"
className=" w-full text-center"
loading={submitLoader}
/>
<div className="flex items-baseline gap-1 justify-center">
<p className="w-fit text-gray-500">New here?</p>
<Link href={"/register"} className="block text-sky-700 font-bold">
Sign Up
</Link>
</div>
{process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? undefined : (
<div className="flex items-baseline gap-1 justify-center">
<p className="w-fit text-gray-500 dark:text-gray-400">New here?</p>
<Link
href={"/register"}
className="block text-black dark:text-white font-semibold"
>
Sign Up
</Link>
</div>
)}
</div>
<p className="text-center text-xs text-gray-500 mb-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</>
</CenteredForm>
);
}
+49 -9
View File
@@ -1,16 +1,35 @@
"use client";
import LinkCard from "@/components/PublicPage/LinkCard";
import useDetectPageBottom from "@/hooks/useDetectPageBottom";
import getPublicCollectionData from "@/lib/client/getPublicCollectionData";
import { PublicCollectionIncludingLinks } from "@/types/global";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { motion, Variants } from "framer-motion";
import Head from "next/head";
const cardVariants: Variants = {
offscreen: {
y: 50,
opacity: 0,
},
onscreen: {
y: 0,
opacity: 1,
transition: {
duration: 0.4,
},
},
};
export default function PublicCollections() {
const router = useRouter();
const hasReachedBottom = useDetectPageBottom();
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const [data, setData] = useState<PublicCollectionIncludingLinks>();
document.body.style.background = "white";
useEffect(() => {
if (router.query.id) {
getPublicCollectionData(
@@ -31,23 +50,33 @@ export default function PublicCollections() {
}, []);
useEffect(() => {
if (hasReachedBottom && router.query.id) {
if (reachedBottom && router.query.id) {
getPublicCollectionData(
Number(router.query.id),
data as PublicCollectionIncludingLinks,
setData
);
}
}, [hasReachedBottom]);
setReachedBottom(false);
}, [reachedBottom]);
return data ? (
<div className="max-w-4xl mx-auto p-5 bg">
{data ? (
<Head>
<title>{data.name} | Linkwarden</title>
<meta
property="og:title"
content={`${data.name} | Linkwarden`}
key="title"
/>
</Head>
) : undefined}
<div
className={`text-center bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% rounded-3xl shadow-lg p-5`}
className={`border border-solid border-sky-100 text-center bg-gradient-to-tr from-sky-100 from-10% via-gray-100 via-20% rounded-3xl shadow-lg p-5`}
>
<p className="text-5xl text-sky-700 font-bold mb-5 capitalize">
{data.name}
</p>
<p className="text-5xl text-black mb-5 capitalize">{data.name}</p>
{data.description && (
<>
@@ -59,12 +88,23 @@ export default function PublicCollections() {
<div className="flex flex-col gap-5 my-8">
{data?.links?.map((e, i) => {
return <LinkCard key={i} link={e} count={i} />;
return (
<motion.div
key={i}
initial="offscreen"
whileInView="onscreen"
viewport={{ once: true, amount: 0.8 }}
>
<motion.div variants={cardVariants}>
<LinkCard link={e} count={i} />
</motion.div>
</motion.div>
);
})}
</div>
{/* <p className="text-center font-bold text-gray-500">
List created with <span className="text-sky-700">Linkwarden.</span>
List created with <span className="text-black">Linkwarden.</span>
</p> */}
</div>
) : (
+121 -116
View File
@@ -3,7 +3,9 @@ import { useState } from "react";
import { toast } from "react-hot-toast";
import SubmitButton from "@/components/SubmitButton";
import { signIn } from "next-auth/react";
import Image from "next/image";
import { useRouter } from "next/router";
import CenteredForm from "@/layouts/CenteredForm";
import TextInput from "@/components/TextInput";
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
@@ -17,6 +19,7 @@ type FormData = {
export default function Register() {
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const [form, setForm] = useState<FormData>({
name: "",
@@ -27,7 +30,7 @@ export default function Register() {
});
async function registerUser() {
const checkHasEmptyFields = () => {
const checkFields = () => {
if (emailEnabled) {
return (
form.name !== "" &&
@@ -45,14 +48,7 @@ export default function Register() {
}
};
const sendConfirmation = async () => {
await signIn("email", {
email: form.email,
callbackUrl: "/",
});
};
if (checkHasEmptyFields()) {
if (checkFields()) {
if (form.password !== form.passwordConfirmation)
return toast.error("Passwords do not match.");
else if (form.password.length < 8)
@@ -77,7 +73,12 @@ export default function Register() {
setSubmitLoader(false);
if (response.ok) {
if (form.email) await sendConfirmation();
if (form.email && emailEnabled)
await signIn("email", {
email: form.email,
callbackUrl: "/",
});
else if (!emailEnabled) router.push("/login");
toast.success("User Created!");
} else {
@@ -89,148 +90,152 @@ export default function Register() {
}
return (
<>
<Image
src="/linkwarden.png"
width={518}
height={145}
alt="Linkwarden"
className="h-12 w-fit mx-auto mt-10"
/>
<p className="text-center px-2 text-xl font-semibold text-sky-700">
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE
? `Start using our premium services with a ${
<CenteredForm
text={
process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE
? `Start using our Premium Services with a ${
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
}-day free trial!`
: "Create a new account"}
</p>
<div className="p-2 mx-auto my-10 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 rounded-md border border-sky-100">
<p className="text-xl text-sky-700 w-fit font-bold">
Enter your details
</p>
<div>
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">
Display Name
: "Create a new account"
}
>
{process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? (
<div className="p-4 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700">
<p>
Registration is disabled for this instance, please contact the admin
in case of any issues.
</p>
<input
type="text"
placeholder="Johnny"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
{emailEnabled ? undefined : (
) : (
<div className="p-4 flex flex-col gap-3 justify-between sm:w-[30rem] w-80 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700">
<p className="text-2xl text-black dark:text-white text-center font-bold">
Enter your details
</p>
<div>
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">
Username
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Display Name
</p>
<input
type="text"
placeholder="john"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
<TextInput
placeholder="Johnny"
value={form.name}
className="bg-white"
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
)}
{emailEnabled ? (
<div>
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">
Email
</p>
{emailEnabled ? undefined : (
<div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Username
</p>
<input
type="email"
placeholder="johnny@example.com"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
) : undefined}
<TextInput
placeholder="john"
value={form.username}
className="bg-white"
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</div>
)}
{emailEnabled ? (
<div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Email
</p>
<TextInput
type="email"
placeholder="johnny@example.com"
value={form.email}
className="bg-white"
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
) : undefined}
<div className="flex item-center gap-2">
<div className="w-full">
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Password
</p>
<input
<TextInput
type="password"
placeholder="••••••••••••••"
value={form.password}
className="bg-white"
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
<div className="w-full">
<p className="text-sm text-sky-700 w-fit font-semibold mb-1">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1">
Confirm Password
</p>
<input
<TextInput
type="password"
placeholder="••••••••••••••"
value={form.passwordConfirmation}
className="bg-white"
onChange={(e) =>
setForm({ ...form, passwordConfirmation: e.target.value })
}
className="w-full rounded-md p-2 mx-auto border-sky-100 border-solid border outline-none focus:border-sky-700 duration-100"
/>
</div>
</div>
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? (
<>
<p className="text-xs text-gray-500">
By signing up, you agree to our{" "}
<Link href="https://linkwarden.app/tos" className="font-semibold">
Terms of Service
</Link>{" "}
and{" "}
<Link
href="https://linkwarden.app/privacy-policy"
className="font-semibold"
>
Privacy Policy
</Link>
.
</p>
<p className="text-xs text-gray-500">
Need help?{" "}
<Link
href="mailto:support@linkwarden.app"
className="font-semibold"
>
Get in touch
</Link>
.
</p>
</>
) : undefined}
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">
By signing up, you agree to our{" "}
<Link
href="https://linkwarden.app/tos"
className="font-semibold underline"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="https://linkwarden.app/privacy-policy"
className="font-semibold underline"
>
Privacy Policy
</Link>
.
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Need help?{" "}
<Link
href="mailto:support@linkwarden.app"
className="font-semibold underline"
>
Get in touch
</Link>
.
</p>
</div>
) : undefined}
<SubmitButton
onClick={registerUser}
label="Sign Up"
className="mt-2 w-full text-center"
loading={submitLoader}
/>
<div className="flex items-baseline gap-1 justify-center">
<p className="w-fit text-gray-500">Already have an account?</p>
<Link href={"/login"} className="block text-sky-700 font-bold">
Login
</Link>
<SubmitButton
onClick={registerUser}
label="Sign Up"
className="mt-2 w-full text-center"
loading={submitLoader}
/>
<div className="flex items-baseline gap-1 justify-center">
<p className="w-fit text-gray-500 dark:text-gray-400">
Already have an account?
</p>
<Link
href={"/login"}
className="block text-black dark:text-white font-bold"
>
Login
</Link>
</div>
</div>
</div>
<p className="text-center text-xs text-gray-500 mb-10">
© {new Date().getFullYear()} Linkwarden. All rights reserved.{" "}
</p>
</>
)}
</CenteredForm>
);
}

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