Compare commits

...

118 Commits

Author SHA1 Message Date
Daniel 0f40578ca9 Merge pull request #316 from linkwarden/dev
bug fixed
2023-11-24 21:59:16 +03:30
daniel31x13 676c7c3a5d bug fixed 2023-11-24 13:28:47 -05:00
Daniel 544585afd9 Merge pull request #315 from linkwarden/dev
Dev
2023-11-24 21:22:49 +03:30
daniel31x13 87196b1190 minor change 2023-11-24 12:52:07 -05:00
daniel31x13 94d1bbbfba bug fixed 2023-11-24 12:51:43 -05:00
Daniel f78eefbb3b Merge pull request #314 from linkwarden/dev
v2.3.0
2023-11-24 20:52:57 +03:30
daniel31x13 828e8eae2e updated README 2023-11-23 15:47:34 -05:00
daniel31x13 1b53fb139d updated README 2023-11-23 15:43:38 -05:00
daniel31x13 c5d9f2c127 improvements 2023-11-23 09:03:47 -05:00
daniel31x13 f25b83bc09 updated README 2023-11-23 05:20:21 -05:00
daniel31x13 9a15ca9684 updated README 2023-11-20 16:03:38 -05:00
daniel31x13 557494747d improvements + updated README 2023-11-20 15:58:32 -05:00
daniel31x13 5968bc6c9c temporarily disabled daisyUI 2023-11-20 12:53:15 -05:00
daniel31x13 cf7b18e012 added apikey model 2023-11-20 12:48:41 -05:00
daniel31x13 9ad277c784 bug fixed 2023-11-19 22:32:23 -05:00
daniel31x13 9f181fb15e bug fixed 2023-11-19 22:28:02 -05:00
daniel31x13 0c6911aaf0 updated route + bug fixed 2023-11-19 16:22:27 -05:00
daniel31x13 bd16136946 minor change to README 2023-11-19 14:45:44 -05:00
daniel31x13 9eee3eea1d minor change to README 2023-11-19 14:44:41 -05:00
daniel31x13 0579395e93 minor change 2023-11-19 14:40:11 -05:00
daniel31x13 988d647521 more concise .env.sample 2023-11-19 09:19:35 -05:00
daniel31x13 e628b3a6d5 rename "Refresh Format" to "Refresh Link" 2023-11-19 09:15:29 -05:00
Daniel c73f13a9b0 Merge pull request #310 from linkwarden/keycloak-integration
Keycloak integration
2023-11-19 17:30:10 +03:30
daniel31x13 9a28552af5 bug fix 2023-11-19 08:56:03 -05:00
daniel31x13 9938d21499 minor changes 2023-11-19 08:38:05 -05:00
Daniel eb78fb71d9 Merge pull request #284 from BTLzdravtech/dev
feat: Basic support for Keycloak (OIDC) + fix s3 integration + custom s3 (minio) support
2023-11-19 16:55:09 +03:30
Daniel e2fdb11a67 Merge pull request #309 from linkwarden/improved-public-page
minor fix
2023-11-19 16:53:49 +03:30
daniel31x13 e2f9439d40 minor fix 2023-11-19 08:18:10 -05:00
Daniel 30c9c86e22 Merge pull request #308 from linkwarden/improved-public-page
Improved public page
2023-11-19 16:43:24 +03:30
daniel31x13 614d92f050 finished the public page 2023-11-19 08:12:37 -05:00
daniel31x13 b50ec09727 minor fix 2023-11-16 06:52:19 -05:00
daniel31x13 01602bafec improved public page [WIP] 2023-11-16 06:51:28 -05:00
daniel31x13 d972ec2dab better public page [WIP] 2023-11-16 03:22:16 -05:00
daniel31x13 021f7c9481 added view team modal 2023-11-15 23:31:13 -05:00
daniel31x13 59815f47d8 refactored public page endpoints 2023-11-15 13:12:06 -05:00
Daniel b868318548 Merge pull request #299 from linkwarden/dev
swapped changelog url
2023-11-12 00:45:27 +03:30
daniel31x13 09ee81bf11 swapped changelog url 2023-11-11 16:15:07 -05:00
Daniel 31663faa5a Merge pull request #298 from linkwarden/dev
small change
2023-11-12 00:41:47 +03:30
daniel31x13 88a8c21aa4 small change 2023-11-11 16:11:08 -05:00
Daniel cd8081e610 Merge pull request #297 from linkwarden/dev
minor fix
2023-11-12 00:37:26 +03:30
daniel31x13 1e0aaed833 minor fix 2023-11-11 16:06:50 -05:00
Daniel 34eec78ba4 Merge pull request #296 from linkwarden/dev
minor visual changes and fixes
2023-11-12 00:28:25 +03:30
daniel31x13 11c834c61b minor visual changes and fixes 2023-11-11 15:56:45 -05:00
Daniel aefcd6d311 Merge pull request #295 from linkwarden/dev
Dev
2023-11-11 23:31:47 +03:30
daniel31x13 dd09fd9026 update version number 2023-11-11 15:00:48 -05:00
daniel31x13 b19d6694ec add route for pinned links + better dashboard UX 2023-11-11 14:57:46 -05:00
daniel31x13 49b1ea4875 more customizable link icons 2023-11-11 14:00:38 -05:00
Daniel ea82fb5825 Merge pull request #291 from linkwarden/visual-improvements
Visual improvements
2023-11-11 07:37:25 +03:30
daniel31x13 e3b32fd791 improved dashboard design + blurred icons based on personal preferences 2023-11-10 22:32:56 -05:00
daniel31x13 3dfbccaf23 better looking dashboard 2023-11-09 11:44:49 -05:00
Tomáš Hruška 836dc10c2b fix: s3 integration + custom s3 (minio) support 2023-11-09 11:41:29 +01:00
Tomáš Hruška 946eed3773 feat: basic support for Keycloak (OIDC) 2023-11-09 11:41:08 +01:00
Daniel 359507c014 Merge pull request #278 from linkwarden/dev
minor fix
2023-11-08 03:01:41 +03:30
daniel31x13 518b94b1f4 minor fix 2023-11-07 18:30:55 -05:00
Daniel f21033bd7a Merge pull request #277 from linkwarden/dev
Dev
2023-11-08 02:52:06 +03:30
daniel31x13 fbc1d4b113 hardcoded import size limit to 10mb to pass build error 2023-11-07 18:21:27 -05:00
daniel31x13 dba62d7028 updated README 2023-11-07 17:55:11 -05:00
Daniel 3aafc0960c Merge pull request #274 from linkwarden/dev
Linkwarden v2.0
2023-11-07 23:25:42 +03:30
daniel31x13 a2f03ff468 added hover effect to announcement bar components 2023-11-07 14:54:37 -05:00
daniel31x13 c300da172b added url to announcment bar 2023-11-07 14:51:22 -05:00
daniel31x13 2f4af7f3d9 added announcement bar 2023-11-07 13:06:42 -05:00
daniel31x13 cb5b1751c0 bug fix 2023-11-07 08:03:35 -05:00
daniel31x13 6f5245cbc4 minor change 2023-11-06 10:54:39 -05:00
daniel31x13 9bee9b8ae4 bug fix 2023-11-06 10:06:14 -05:00
daniel31x13 7bdef522c1 bug fixed 2023-11-06 10:01:39 -05:00
daniel31x13 c8edc3844b code refactoring + many security/bug fixes 2023-11-06 08:25:57 -05:00
daniel31x13 b5a28f68ad remove tag functionality 2023-11-03 00:09:50 -04:00
daniel31x13 ae1889e757 support for bearer tokens 2023-11-02 14:59:31 -04:00
daniel31x13 b458fad567 WIP changes 2023-11-02 01:52:49 -04:00
daniel31x13 b1b0d98eb2 search by text content functionality 2023-11-01 06:01:26 -04:00
daniel31x13 b1c6a3faf1 readable format styling 2023-10-31 18:02:41 -04:00
daniel31x13 56a281ae3d rearchive protection 2023-10-31 15:44:58 -04:00
daniel31x13 ccafc997fc minor change 2023-10-31 05:41:19 -04:00
daniel31x13 417c16d08b minor UI change 2023-10-31 05:39:05 -04:00
daniel31x13 dbeefecec6 better design 2023-10-31 05:35:45 -04:00
daniel31x13 35665ce292 minor fix 2023-10-30 15:21:16 -04:00
daniel31x13 fb61812356 fully added reader view support 2023-10-30 15:20:15 -04:00
daniel31x13 ed91c4267b changed readable format to json 2023-10-30 00:50:43 -04:00
daniel31x13 c9c62b615b finished implementing readable mode api side 2023-10-30 00:30:45 -04:00
daniel31x13 de20fb7bc1 minor change 2023-10-29 12:56:38 -04:00
daniel31x13 16024f40be added new api route + fixed dropdown 2023-10-29 00:57:24 -04:00
daniel31x13 2856e23a4a fixed the dropdown 2023-10-28 12:50:11 -04:00
daniel31x13 db47a2a142 [WIP] dropdown bug 2023-10-28 07:20:35 -04:00
daniel31x13 ac795cdbdc added rearchive functionallity + dropdown fix [WIP] 2023-10-28 05:57:53 -04:00
daniel31x13 9b6038201c bug fixed 2023-10-28 01:46:51 -04:00
daniel31x13 9486d699c9 bug fixed 2023-10-28 01:42:31 -04:00
daniel31x13 cdcfabec0b refactored how avatars are being handled 2023-10-28 00:45:14 -04:00
Daniel f9eedadb9f Merge pull request #265 from linkwarden/dependabot/npm_and_yarn/crypto-js-4.2.0
Bump crypto-js from 4.1.1 to 4.2.0
2023-10-27 23:04:37 -04:00
daniel31x13 c08e7d4580 updated prisma schema 2023-10-27 16:06:42 -04:00
daniel31x13 ea86737835 bugs fixed 2023-10-26 18:49:46 -04:00
dependabot[bot] 788fc56caf Bump crypto-js from 4.1.1 to 4.2.0
Bumps [crypto-js](https://github.com/brix/crypto-js) from 4.1.1 to 4.2.0.
- [Commits](https://github.com/brix/crypto-js/compare/4.1.1...4.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-25 23:23:38 +00:00
daniel31x13 966136dab6 created migration script [WIP] 2023-10-25 15:42:36 -04:00
Daniel 4454e615b6 Merge pull request #262 from linkwarden/dev
Dev
2023-10-24 17:23:14 -04:00
Daniel 91748ac5d2 Update issue templates 2023-10-24 17:22:45 -04:00
daniel31x13 2be2a83c62 minor fix 2023-10-24 17:11:25 -04:00
daniel31x13 c38c5b2cc5 improved user experience 2023-10-24 17:03:33 -04:00
daniel31x13 cb8c2d5f10 finished adding profile deletion functionality + bug fix 2023-10-24 15:57:37 -04:00
Daniel 8fdc503f55 Merge pull request #258 from linkwarden/dev
Dev
2023-10-23 15:25:19 -04:00
daniel31x13 97d8c35d2a added delete user endpoint 2023-10-23 15:24:22 -04:00
Daniel 4ffbc4e2f6 Update issue templates 2023-10-23 22:04:44 +03:30
daniel31x13 4252b79586 added recent links to dashboard 2023-10-23 10:45:48 -04:00
Daniel ee4554ae95 Merge pull request #253 from linkwarden/dev
fixed UI bug
2023-10-23 01:56:24 -04:00
daniel31x13 697b139493 fixed UI bug 2023-10-23 01:55:44 -04:00
Daniel a4ea023c51 Merge pull request #252 from linkwarden/dev
Dev
2023-10-23 01:46:23 -04:00
daniel31x13 bcae97a296 bug fixed 2023-10-23 01:45:31 -04:00
Daniel 565ee92d20 Merge pull request #236 from YeeJiaWei/login-with-enter
html form for login & register using enter key
2023-10-23 01:21:05 -04:00
daniel31x13 ec4bfa6ba9 merged "AuthSubmitButton" with the "SubmitButton" + updated the other pages that needed this change 2023-10-23 01:20:08 -04:00
Daniel 68f0f03d0d Merge pull request #251 from linkwarden/main
update issue templates
2023-10-23 00:31:39 -04:00
Daniel 86cfdd508a Merge pull request #250 from linkwarden/dev
refactored/cleaned up API + added support for renaming tags
2023-10-23 00:30:30 -04:00
daniel31x13 ed24685aaf refactored/cleaned up API + added support for renaming tags 2023-10-23 00:28:39 -04:00
Daniel 84d4153b5c Update issue templates 2023-10-23 00:26:26 -04:00
Daniel ad525b8b00 Merge pull request #243 from linkwarden/dev
increase timeout to pass github actions arm64 build
2023-10-20 23:59:26 -04:00
daniel31x13 24cced9dba increase timeout to pass github actions arm64 build 2023-10-20 23:58:38 -04:00
Daniel 3626ea613c Merge pull request #242 from linkwarden/dev
minor change to DockerFile
2023-10-20 23:07:25 -04:00
daniel31x13 aaebdc5da7 minor change to DockerFile 2023-10-20 23:06:09 -04:00
Daniel 748f181bc2 Merge pull request #241 from linkwarden/dev
downgrade node to pass build
2023-10-20 22:50:10 -04:00
daniel31x13 d7705b585e downgrade node to pass build 2023-10-20 22:49:43 -04:00
Yee Jia Wei b3295e136d change login and register to form 2023-10-19 13:46:40 +08:00
190 changed files with 6982 additions and 2858 deletions
+14 -13
View File
@@ -1,33 +1,34 @@
NEXTAUTH_SECRET=very_sensitive_secret
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
NEXTAUTH_URL=http://localhost:3000
# Additional Optional Settings
# Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
# Docker installation database settings
POSTGRES_PASSWORD=super_secret_password
# Additional Optional Settings
PAGINATION_TAKE_COUNT=
STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION=
RE_ARCHIVE_LIMIT=
# AWS S3 Settings
SPACES_KEY=
SPACES_SECRET=
SPACES_ENDPOINT=
SPACES_BUCKET_NAME=
SPACES_REGION=
SPACES_FORCE_PATH_STYLE=
# SMTP Settings
NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM=
EMAIL_SERVER=
# Stripe settings (You don't need these, it's for the cloud instance payments)
NEXT_PUBLIC_STRIPE_IS_ACTIVE=
STRIPE_SECRET_KEY=
MONTHLY_PRICE_ID=
YEARLY_PRICE_ID=
NEXT_PUBLIC_TRIAL_PERIOD_DAYS=
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
BASE_URL=http://localhost:3000
# Docker postgres settings
POSTGRES_PASSWORD=
# Keycloak
NEXT_PUBLIC_KEYCLOAK_ENABLED=
KEYCLOAK_ISSUER=
KEYCLOAK_CLIENT_ID=
KEYCLOAK_CLIENT_SECRET=
+20
View File
@@ -0,0 +1,20 @@
---
name: Ask a Question
about: Ask about a particular topic
title: ''
labels: question
assignees: ''
---
**Is your question related to a problem or code? Please describe.**
A clear and concise description of what the problem or code is. Ex. I'm confused about how [...] works, or I'm facing an issue when [...]
**Describe what you've tried to solve this question**
Explain what steps or research you've already taken to try and understand or solve this.
**Include any code or screenshots (if applicable)**
Add any code snippets, error messages, or screenshots that might help others understand your question better.
**Additional context**
Include any additional context or details that might help get a clearer understanding of your question.
@@ -1,8 +1,8 @@
---
name: Bug report
name: Bug Report
about: Create a report to help us improve
title: ''
labels: ''
labels: bug
assignees: ''
---
@@ -1,8 +1,8 @@
---
name: Feature request
name: Feature Request
about: Suggest an idea for this project
title: ''
labels: ''
labels: enhancement
assignees: ''
---
+5 -4
View File
@@ -1,5 +1,4 @@
# playwright doesnt support debian image
FROM node:20-bullseye-slim
FROM node:18.18-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
@@ -9,8 +8,10 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN yarn && \
npx playwright install-deps && \
# Increase timeout to pass github actions arm64 build
RUN yarn install --network-timeout 10000000
RUN npx playwright install-deps && \
apt-get clean && \
yarn cache clean
+30 -24
View File
@@ -11,7 +11,7 @@
<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-)
[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) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
</div>
@@ -21,38 +21,55 @@
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**
> [!TIP]
> 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.
<img src="./assets/dashboard.png" />
<div align="center">
<img src="./assets/all_links.png" width="32%" />
<img src="./assets/all_collections.png" width="32%" />
<img src="./assets/manage_team.png" width="32%" />
<img src="./assets/readable_view.png" width="32%" />
<img src="./assets/public_page.png" width="32%" />
<img src="./assets/light_mode.png" width="32%" />
</div>
<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).
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.
- 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- 📂 Organize links by collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection.
- 🔐 Customize the permissions of each member.
- 🌐 Share your collected links with the world.
- 🎛️ Customize the permissions of each member.
- 🌐 Share your collected links and preserved formats with the world.
- 📌 Pin your favorite links to dashboard.
- 🔍 Search, filter and sort by link details.
- 📱 Responsive design and supports most browsers.
- 🔍 Full text search, filter and sort for easy retrieval.
- 📱 Responsive design and supports most modern browsers.
- 🌓 Dark/Light mode support.
- 🧩 Browser extension, managed by the community [check it out!](https://github.com/linkwarden/browser-extension)
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import your bookmarks from other browsers.
- ⚡️ Powerful API.
- 🔐 SSO and Keycloak integration. (Enterprise and Self-hosted users only)
- ✅ And many more features!
## 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! :)
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
@@ -78,16 +95,6 @@ If you want to contribute, Thanks! Start by checking our [public roadmap](https:
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!
@@ -96,7 +103,6 @@ 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: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

+35
View File
@@ -0,0 +1,35 @@
import { faClose } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Link from "next/link";
import React, { MouseEventHandler } from "react";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
return (
<div className="fixed w-full z-20 dark:bg-neutral-900 bg-white">
<div className="w-full h-10 rainbow flex items-center justify-center">
<div className="w-fit font-semibold">
🎉{" "}
<Link
href="https://blog.linkwarden.app/releases/v2.0"
target="_blank"
className="underline hover:opacity-50 duration-100"
>
Linkwarden v2.0
</Link>{" "}
is now out! 🥳
</div>
<button
className="fixed top-3 right-3 hover:opacity-50 duration-100"
onClick={toggleAnnouncementBar}
>
<FontAwesomeIcon icon={faClose} className="w-4 h-4" />
</button>
</div>
</div>
);
}
+4 -4
View File
@@ -11,7 +11,9 @@ type Props = {
export default function Checkbox({ label, state, className, onClick }: Props) {
return (
<label className={`cursor-pointer flex items-center gap-2 ${className}`}>
<label
className={`cursor-pointer flex items-center gap-2 ${className || ""}`}
>
<input
type="checkbox"
checked={state}
@@ -26,9 +28,7 @@ export default function Checkbox({ label, state, className, onClick }: Props) {
icon={faSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:hidden block"
/>
<span className="text-black dark:text-white rounded select-none">
{label}
</span>
<span className="rounded select-none">{label}</span>
</label>
);
}
+14 -2
View File
@@ -4,6 +4,8 @@ type Props = {
children: ReactNode;
onClickOutside: Function;
className?: string;
style?: React.CSSProperties;
onMount?: (rect: DOMRect) => void;
};
function useOutsideAlerter(
@@ -30,12 +32,22 @@ export default function ClickAwayHandler({
children,
onClickOutside,
className,
style,
onMount,
}: Props) {
const wrapperRef = useRef(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
useOutsideAlerter(wrapperRef, onClickOutside);
useEffect(() => {
if (wrapperRef.current && onMount) {
const rect = wrapperRef.current.getBoundingClientRect();
onMount(rect); // Pass the bounding rectangle to the parent
}
}, []);
return (
<div ref={wrapperRef} className={className}>
<div ref={wrapperRef} className={className} style={style}>
{children}
</div>
);
+80 -61
View File
@@ -1,5 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsis, faLink } from "@fortawesome/free-solid-svg-icons";
import { faEllipsis, faGlobe, faLink } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Dropdown from "./Dropdown";
@@ -15,6 +15,13 @@ type Props = {
className?: string;
};
type DropdownTrigger =
| {
x: number;
y: number;
}
| false;
export default function CollectionCard({ collection, className }: Props) {
const { setModal } = useModalStore();
@@ -29,74 +36,86 @@ export default function CollectionCard({ collection, className }: Props) {
}
);
const [expandDropdown, setExpandDropdown] = useState(false);
const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
const permissions = usePermissions(collection.id as number);
return (
<div
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 hover:dark:bg-neutral-700 duration-100 p-1"
style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
theme === "dark" ? "#262626" : "#f3f4f6"
} 50%, ${theme === "dark" ? "#262626" : "#f9fafb"} 100%)`,
}}
className={`border border-solid border-sky-100 dark:border-neutral-700 self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none hover:opacity-80 group relative ${
className || ""
}`}
>
<FontAwesomeIcon
icon={faEllipsis}
<div
onClick={(e) => setExpandDropdown({ x: e.clientX, y: e.clientY })}
id={"expand-dropdown" + collection.id}
className="w-5 h-5 text-gray-500 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 capitalize text-black dark:text-white break-words line-clamp-3 w-4/5">
{collection.name}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center w-full">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={`/api/avatar/${e.userId}?${Date.now()}`}
className="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{collection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700 -mr-3">
+{collection.members.length - 4}
</div>
) : null}
</div>
<div className="text-right w-40">
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
<FontAwesomeIcon
icon={faLink}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p className="font-bold text-xs">{formattedDate}</p>
</div>
</div>
className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
id={"expand-dropdown" + collection.id}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
</Link>
<Link
href={`/collections/${collection.id}`}
className="flex flex-col gap-2 justify-between min-h-[12rem] h-full select-none p-5"
>
<p className="text-2xl capitalize text-black dark:text-white break-words line-clamp-3 w-4/5">
{collection.name}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center w-full">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
className="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{collection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700 -mr-3">
+{collection.members.length - 4}
</div>
) : null}
</div>
<div className="text-right w-40">
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? (
<FontAwesomeIcon
icon={faGlobe}
title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
<FontAwesomeIcon
icon={faLink}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
{collection._count && collection._count.links}
</div>
<div className="flex items-center justify-end gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p className="font-bold text-xs">{formattedDate}</p>
</div>
</div>
</div>
</Link>
</div>
{expandDropdown ? (
<Dropdown
points={{ x: expandDropdown.x, y: expandDropdown.y }}
items={[
permissions === true
? {
@@ -152,9 +171,9 @@ export default function CollectionCard({ collection, className }: Props) {
if (target.id !== "expand-dropdown" + collection.id)
setExpandDropdown(false);
}}
className="absolute top-[3.2rem] right-5 z-10"
className="w-fit"
/>
) : null}
</div>
</>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props = {
name: string;
value: number;
icon: IconProp;
};
export default function dashboardItem({ name, value, icon }: Props) {
return (
<div className="flex gap-4 items-end">
<div className="p-4 bg-sky-500 bg-opacity-20 dark:bg-opacity-10 rounded-xl select-none">
<FontAwesomeIcon
icon={icon}
className="w-8 h-8 text-sky-500 dark:text-sky-500"
/>
</div>
<div className="flex flex-col justify-center">
<p className="text-gray-500 dark:text-gray-400 text-sm tracking-wider">
{name}
</p>
<p className="font-thin text-6xl text-sky-500 dark:text-sky-500 mt-2">
{value}
</p>
</div>
</div>
);
}
+58 -5
View File
@@ -1,5 +1,5 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import React, { MouseEventHandler, useEffect, useState } from "react";
import ClickAwayHandler from "./ClickAwayHandler";
type MenuItem =
@@ -19,13 +19,66 @@ type Props = {
onClickOutside: Function;
className?: string;
items: MenuItem[];
points?: { x: number; y: number };
style?: React.CSSProperties;
};
export default function Dropdown({ onClickOutside, className, items }: Props) {
return (
export default function Dropdown({
points,
onClickOutside,
className,
items,
}: Props) {
const [pos, setPos] = useState<{ x: number; y: number }>();
const [dropdownHeight, setDropdownHeight] = useState<number>();
const [dropdownWidth, setDropdownWidth] = useState<number>();
function convertRemToPixels(rem: number) {
return (
rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
);
}
useEffect(() => {
if (points) {
let finalX = points.x;
let finalY = points.y;
// Check for x-axis overflow (left side)
if (dropdownWidth && points.x + dropdownWidth > window.innerWidth) {
finalX = points.x - dropdownWidth;
}
// Check for y-axis overflow (bottom side)
if (dropdownHeight && points.y + dropdownHeight > window.innerHeight) {
finalY =
window.innerHeight -
(dropdownHeight + (window.innerHeight - points.y));
}
setPos({ x: finalX, y: finalY });
}
}, [points, dropdownHeight]);
return !points || pos ? (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${className} py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 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 && (
@@ -49,5 +102,5 @@ export default function Dropdown({ onClickOutside, className, items }: Props) {
);
})}
</ClickAwayHandler>
);
) : null;
}
+17 -2
View File
@@ -1,12 +1,17 @@
import React, { SetStateAction } from "react";
import ClickAwayHandler from "./ClickAwayHandler";
import Checkbox from "./Checkbox";
import { LinkSearchFilter } from "@/types/global";
type Props = {
setFilterDropdown: (value: SetStateAction<boolean>) => void;
setSearchFilter: Function;
searchFilter: LinkSearchFilter;
searchFilter: {
name: boolean;
url: boolean;
description: boolean;
textContent: boolean;
tags: boolean;
};
};
export default function FilterSearchDropdown({
@@ -50,6 +55,16 @@ export default function FilterSearchDropdown({
})
}
/>
<Checkbox
label="Full Content"
state={searchFilter.textContent}
onClick={() =>
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
})
}
/>
<Checkbox
label="Tags"
state={searchFilter.tags}
-4
View File
@@ -36,10 +36,6 @@ export const styles: StylesConfig = {
...styles,
cursor: "pointer",
}),
clearIndicator: (styles) => ({
...styles,
visibility: "hidden",
}),
placeholder: (styles) => ({
...styles,
borderColor: "black",
+143 -90
View File
@@ -21,6 +21,7 @@ import { toast } from "react-hot-toast";
import isValidUrl from "@/lib/client/isValidUrl";
import Link from "next/link";
import unescapeString from "@/lib/client/unescapeString";
import { useRouter } from "next/router";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
@@ -28,12 +29,21 @@ type Props = {
className?: string;
};
type DropdownTrigger =
| {
x: number;
y: number;
}
| false;
export default function LinkCard({ link, count, className }: Props) {
const { setModal } = useModalStore();
const router = useRouter();
const permissions = usePermissions(link.collection.id as number);
const [expandDropdown, setExpandDropdown] = useState(false);
const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
const { collections } = useCollectionStore();
@@ -64,7 +74,7 @@ export default function LinkCard({ link, count, className }: Props) {
);
}, [collections, links]);
const { removeLink, updateLink } = useLinkStore();
const { removeLink, updateLink, getLink } = useLinkStore();
const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
@@ -84,10 +94,29 @@ export default function LinkCard({ link, count, className }: Props) {
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
};
const updateArchive = async () => {
const load = toast.loading("Sending request...");
setExpandDropdown(false);
const response = await fetch(`/api/v1/links/${link.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(`Link is being archived...`);
getLink(link.id as number);
} else toast.error(data.response);
};
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const response = await removeLink(link);
const response = await removeLink(link.id as number);
toast.dismiss(load);
@@ -107,100 +136,121 @@ export default function LinkCard({ link, count, className }: Props) {
);
return (
<div
className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${className}`}
>
{(permissions === true ||
permissions?.canUpdate ||
permissions?.canDelete) && (
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id={"expand-dropdown" + link.id}
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
title="More"
className="w-5 h-5"
id={"expand-dropdown" + link.id}
/>
</div>
)}
<>
<div
onClick={() => {
setModal({
modal: "LINK",
state: true,
method: "UPDATE",
isOwnerOrMod:
permissions === true || (permissions?.canUpdate as boolean),
active: link,
});
}}
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5"
className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${
className || ""
}`}
>
{url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={64}
height={64}
alt=""
className="blur-sm absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
{(permissions === true ||
permissions?.canUpdate ||
permissions?.canDelete) && (
<div
onClick={(e) => {
setExpandDropdown({ x: e.clientX, y: e.clientY });
}}
/>
id={"expand-dropdown" + link.id}
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-4 top-4 z-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
title="More"
className="w-5 h-5"
id={"expand-dropdown" + link.id}
/>
</div>
)}
<div className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1">
<p className="text-sm text-gray-500 dark:text-gray-300">
{count + 1}
</p>
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8">
{unescapeString(link.name || link.description)}
</p>
</div>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
<div
onClick={() => router.push("/links/" + link.id)}
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-4"
>
{url && account.displayLinkIcons && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={64}
height={64}
alt=""
className={`${
account.blurredFavicons ? "blur-sm " : ""
}absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none z-10`}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
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>
</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 className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1">
<p className="text-sm text-gray-500 dark:text-gray-300">
{count + 1}
</p>
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8">
{unescapeString(link.name || link.description)}
</p>
</div>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit my-1 hover:opacity-70 duration-100"
>
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }}
/>
<p className="text-black dark:text-white truncate capitalize w-full">
{collection?.name}
</p>
</Link>
{/* {link.tags[0] ? (
<div className="flex gap-3 items-center flex-wrap my-2 truncate relative">
<div className="flex gap-1 items-center flex-nowrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="px-2 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}
</Link>
))}
</div>
<div className="absolute w-1/2 top-0 bottom-0 right-0 bg-gradient-to-r from-transparent to-slate-100 dark:to-neutral-800 to-35%"></div>
</div>
) : undefined} */}
<Link
href={link.url}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100"
>
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</Link>
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
</div>
</div>
</div>
{expandDropdown ? (
<Dropdown
points={{ x: expandDropdown.x, y: expandDropdown.y }}
items={[
permissions === true
? {
@@ -219,15 +269,18 @@ export default function LinkCard({ link, count, className }: Props) {
modal: "LINK",
state: true,
method: "UPDATE",
isOwnerOrMod:
permissions === true || permissions?.canUpdate,
active: link,
defaultIndex: 1,
});
setExpandDropdown(false);
},
}
: undefined,
permissions === true
? {
name: "Refresh Link",
onClick: updateArchive,
}
: undefined,
permissions === true || permissions?.canDelete
? {
name: "Delete",
@@ -240,9 +293,9 @@ export default function LinkCard({ link, count, className }: Props) {
if (target.id !== "expand-dropdown" + link.id)
setExpandDropdown(false);
}}
className="absolute top-12 right-5 w-36"
className="w-40"
/>
) : null}
</div>
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { faFolder, faLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/client/isValidUrl";
import A from "next/link";
import unescapeString from "@/lib/client/unescapeString";
import { Link } from "@prisma/client";
type Props = {
link?: Partial<Link>;
className?: string;
settings: {
blurredFavicons: boolean;
displayLinkIcons: boolean;
};
};
export default function LinkPreview({ link, className, settings }: Props) {
if (!link) {
link = {
name: "Linkwarden",
url: "https://linkwarden.app",
createdAt: Date.now() as unknown as Date,
};
}
let shortendURL;
try {
shortendURL = new URL(link.url as string).host.toLowerCase();
} catch (error) {
console.log(error);
}
const url = isValidUrl(link.url as string)
? new URL(link.url as string)
: undefined;
const formattedDate = new Date(link.createdAt as Date).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
return (
<>
<div
className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${
className || ""
}`}
>
<div className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5">
{url && settings?.displayLinkIcons && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={64}
height={64}
alt=""
className={`${
settings.blurredFavicons ? "blur-sm " : ""
}absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none`}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
)}
<div className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1">
<p className="text-sm text-gray-500 dark:text-gray-300">{1}</p>
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8">
{unescapeString(link.name as string)}
</p>
</div>
<div className="flex items-center gap-1 max-w-full w-fit my-1 hover:opacity-70 duration-100">
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow text-sky-400"
/>
<p className="text-black dark:text-white truncate capitalize w-full">
Landing Pages
</p>
</div>
<A
href={link.url as string}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100"
>
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</A>
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
</div>
</div>
</div>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPen,
faBoxesStacked,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useModalStore from "@/store/modals";
import useLinkStore from "@/store/links";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
type Props = {
className?: string;
onClick?: Function;
};
export default function LinkSidebar({ className, onClick }: Props) {
const session = useSession();
const userId = session.data?.user.id;
const { setModal } = useModalStore();
const { links, removeLink } = useLinkStore();
const { collections } = useCollectionStore();
const [linkCollection, setLinkCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const router = useRouter();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection?.id));
}, [link]);
return (
<div
className={`dark:bg-neutral-900 bg-white h-full lg:w-10 w-62 overflow-y-auto lg:p-0 p-5 border-solid border-white border dark:border-neutral-900 dark:lg:border-r-neutral-900 lg:border-r-white border-r-sky-100 dark:border-r-neutral-700 z-20 flex flex-col gap-5 lg:justify-center justify-start ${
className || ""
}`}
>
<div className="flex flex-col gap-5">
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
<div
title="Edit"
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "UPDATE",
})
: undefined;
onClick && onClick();
}}
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Edit
</p>
</div>
) : undefined}
<div
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined;
onClick && onClick();
}}
title="Preserved Formats"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Preserved Formats
</p>
</div>
{link?.collection.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
<div
onClick={() => {
if (link?.id) {
removeLink(link.id);
router.back();
onClick && onClick();
}
}}
title="Delete"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden">
Delete
</p>
</div>
) : undefined}
</div>
</div>
);
}
+4 -10
View File
@@ -6,7 +6,6 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import RequiredBadge from "../../RequiredBadge";
import SubmitButton from "@/components/SubmitButton";
import { HexColorPicker } from "react-colorful";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -19,7 +18,7 @@ type Props = {
SetStateAction<CollectionIncludingMembersAndLinkCount>
>;
collection: CollectionIncludingMembersAndLinkCount;
method: "CREATE" | "UPDATE";
method: "CREATE" | "UPDATE" | "VIEW_TEAM";
};
export default function CollectionInfo({
@@ -61,10 +60,7 @@ 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-black dark:text-white mb-2">
Name
<RequiredBadge />
</p>
<p className="text-black dark:text-white mb-2">Name</p>
<div className="flex flex-col gap-3">
<TextInput
value={collection.name}
@@ -75,9 +71,7 @@ export default function CollectionInfo({
/>
<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-black dark:text-white mb-2">
Icon Color
</p>
<p className="w-full text-black dark:text-white mb-2">Color</p>
<div style={{ color: collection.color }}>
<FontAwesomeIcon
icon={faFolder}
@@ -102,7 +96,7 @@ export default function CollectionInfo({
</div>
<div className="w-full">
<p className="text-sm text-black dark:text-white mb-2">Description</p>
<p className="text-black dark:text-white mb-2">Description</p>
<textarea
className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-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..."
+17 -40
View File
@@ -9,7 +9,6 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import { useSession } from "next-auth/react";
import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Checkbox from "../../Checkbox";
import SubmitButton from "@/components/SubmitButton";
@@ -18,6 +17,7 @@ import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast";
import getPublicUserData from "@/lib/client/getPublicUserData";
import TextInput from "@/components/TextInput";
import useAccountStore from "@/store/account";
type Props = {
toggleCollectionModal: Function;
@@ -34,31 +34,25 @@ export default function TeamManagement({
collection,
method,
}: Props) {
const { account } = useAccountStore();
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [member, setMember] = useState<Member>({
canCreate: false,
canUpdate: false,
canDelete: false,
user: {
name: "",
username: "",
},
});
const [memberUsername, setMemberUsername] = useState("");
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
image: "",
});
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData({ id: collection.ownerId });
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
@@ -67,8 +61,6 @@ export default function TeamManagement({
const { addCollection, updateCollection } = useCollectionStore();
const session = useSession();
const setMemberState = (newMember: Member) => {
if (!collection) return null;
@@ -77,15 +69,7 @@ export default function TeamManagement({
members: [...collection.members, newMember],
});
setMember({
canCreate: false,
canUpdate: false,
canDelete: false,
user: {
name: "",
username: "",
},
});
setMemberUsername("");
};
const [submitLoader, setSubmitLoader] = useState(false);
@@ -118,7 +102,7 @@ export default function TeamManagement({
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{permissions === true && (
<>
<p className="text-sm text-black dark:text-white">Make Public</p>
<p className="text-black dark:text-white">Make Public</p>
<Checkbox
label="Make this a public collection."
@@ -136,7 +120,7 @@ export default function TeamManagement({
{collection.isPublic ? (
<div>
<p className="text-sm text-black dark:text-white mb-2">
<p className="text-black dark:text-white mb-2">
Public Link (Click to copy)
</p>
<div
@@ -162,25 +146,18 @@ export default function TeamManagement({
{permissions === true && (
<>
<p className="text-sm text-black dark:text-white">
Member Management
</p>
<p className="text-black dark:text-white">Member Management</p>
<div className="flex items-center gap-2">
<TextInput
value={member.user.username || ""}
value={memberUsername || ""}
placeholder="Username (without the '@')"
onChange={(e) => {
setMember({
...member,
user: { ...member.user, username: e.target.value },
});
}}
onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
session.data?.user.username as string,
member.user.username || "",
account.username as string,
memberUsername || "",
collection,
setMemberState
)
@@ -190,8 +167,8 @@ export default function TeamManagement({
<div
onClick={() =>
addMemberToCollection(
session.data?.user.username as string,
member.user.username || "",
account.username as string,
memberUsername || "",
collection,
setMemberState
)
@@ -238,7 +215,7 @@ export default function TeamManagement({
)}
<div className="flex items-center gap-2">
<ProfilePhoto
src={`/api/avatar/${e.userId}?${Date.now()}`}
src={e.user.image ? e.user.image : undefined}
className="border-[3px]"
/>
<div>
@@ -425,7 +402,7 @@ export default function TeamManagement({
>
<div className="flex items-center gap-2">
<ProfilePhoto
src={`/api/avatar/${collection.ownerId}?${Date.now()}`}
src={collectionOwner.image ? collectionOwner.image : undefined}
className="border-[3px]"
/>
<div>
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCrown } from "@fortawesome/free-solid-svg-icons";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import ProfilePhoto from "@/components/ProfilePhoto";
import getPublicUserData from "@/lib/client/getPublicUserData";
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
};
export default function ViewTeam({ collection }: Props) {
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
image: "",
});
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
fetchOwner();
}, []);
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<p className="ml-10 text-xl font-thin">Team</p>
<p>Here are all the members who are collaborating on this collection.</p>
<div
className="relative border px-2 rounded-md border-sky-100 dark:border-neutral-700 flex min-h-[4rem] gap-2 justify-between"
title={`'@${collectionOwner.username}' is the owner of this collection.`}
>
<div className="flex items-center gap-2 w-full">
<ProfilePhoto
src={collectionOwner.image ? collectionOwner.image : undefined}
className="border-[3px]"
/>
<div className="w-full">
<div className="flex items-center gap-1 w-full justify-between">
<p className="text-sm font-bold text-black dark:text-white">
{collectionOwner.name}
</p>
<div className="flex text-xs gap-1 items-center">
<FontAwesomeIcon
icon={faCrown}
className="w-3 h-3 text-yellow-500"
/>
Admin
</div>
</div>
<p className="text-gray-500 dark:text-gray-300">
@{collectionOwner.username}
</p>
</div>
</div>
</div>
{collection?.members[0]?.user && (
<>
<div className="flex flex-col gap-3 rounded-md">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<div
key={i}
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"
>
<div className="flex items-center gap-2">
<ProfilePhoto
src={e.user.image ? e.user.image : undefined}
className="border-[3px]"
/>
<div>
<p className="text-sm font-bold text-black dark:text-white">
{e.user.name}
</p>
<p className="text-gray-500 dark:text-gray-300">
@{e.user.username}
</p>
</div>
</div>
</div>
);
})}
</div>
</>
)}
</div>
);
}
+47 -28
View File
@@ -4,6 +4,7 @@ import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import TeamManagement from "./TeamManagement";
import { useState } from "react";
import DeleteCollection from "./DeleteCollection";
import ViewTeam from "./ViewTeam";
type Props =
| {
@@ -21,6 +22,14 @@ type Props =
isOwner: boolean;
className?: string;
defaultIndex?: number;
}
| {
toggleCollectionModal: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
method: "VIEW_TEAM";
isOwner: boolean;
className?: string;
defaultIndex?: number;
};
export default function CollectionModal({
@@ -46,14 +55,25 @@ export default function CollectionModal({
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="text-xl text-black dark:text-white text-center">
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
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-black dark:text-white">
{method === "UPDATE" && (
<>
{isOwner && (
{method !== "VIEW_TEAM" && (
<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 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
</Tab>
)}
<Tab
className={({ selected }) =>
selected
@@ -61,30 +81,21 @@ export default function CollectionModal({
: "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
{isOwner ? "Share & Collaborate" : "View Team"}
</Tab>
)}
<Tab
className={({ selected }) =>
selected
? "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"}
</Tab>
<Tab
className={({ selected }) =>
selected
? "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"}
</Tab>
</>
)}
</Tab.List>
<Tab
className={({ selected }) =>
selected
? "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"}
</Tab>
</>
)}
</Tab.List>
)}
<Tab.Panels>
{(isOwner || method === "CREATE") && (
<Tab.Panel>
@@ -115,6 +126,14 @@ export default function CollectionModal({
</Tab.Panel>
</>
)}
{method === "VIEW_TEAM" && (
<>
<Tab.Panel>
<ViewTeam collection={collection} />
</Tab.Panel>
</>
)}
</Tab.Panels>
</Tab.Group>
</div>
+21 -26
View File
@@ -4,8 +4,7 @@ import TagSelection from "@/components/InputSelect/TagSelection";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import useLinkStore from "@/store/links";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import RequiredBadge from "../../RequiredBadge";
import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router";
@@ -14,6 +13,7 @@ import { toast } from "react-hot-toast";
import Link from "next/link";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
type Props =
| {
@@ -46,6 +46,10 @@ export default function AddOrEditLink({
url: "",
description: "",
tags: [],
screenshotPath: "",
pdfPath: "",
readabilityPath: "",
textContent: "",
collection: {
name: "",
ownerId: data?.user.id as number,
@@ -133,24 +137,21 @@ export default function AddOrEditLink({
return (
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{method === "UPDATE" ? (
<p
className="text-gray-500 dark:text-gray-300 text-center truncate w-full"
<div
className="text-gray-500 dark:text-gray-300 break-all w-full flex gap-2"
title={link.url}
>
Editing:{" "}
<Link href={link.url} target="_blank">
<FontAwesomeIcon icon={faLink} className="w-6 h-6" />
<Link href={link.url} target="_blank" className="w-full">
{link.url}
</Link>
</p>
</div>
) : null}
{method === "CREATE" ? (
<div 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>
<p className="text-black dark:text-white mb-2">Address (URL)</p>
<TextInput
value={link.url}
onChange={(e) => setLink({ ...link, url: e.target.value })}
@@ -158,9 +159,7 @@ export default function AddOrEditLink({
/>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="text-sm text-black dark:text-white mb-2">
Collection
</p>
<p className="text-black dark:text-white mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
@@ -187,10 +186,10 @@ export default function AddOrEditLink({
{optionsExpanded ? (
<div>
<hr className="mb-3 border border-sky-100 dark:border-neutral-700" />
{/* <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>
<p className="text-black dark:text-white mb-2">Name</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
@@ -200,9 +199,7 @@ export default function AddOrEditLink({
{method === "UPDATE" ? (
<div>
<p className="text-sm text-black dark:text-white mb-2">
Collection
</p>
<p className="text-black dark:text-white mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
@@ -223,7 +220,7 @@ export default function AddOrEditLink({
) : undefined}
<div>
<p className="text-sm text-black dark:text-white mb-2">Tags</p>
<p className="text-black dark:text-white mb-2">Tags</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
@@ -233,9 +230,7 @@ export default function AddOrEditLink({
</div>
<div className="sm:col-span-2">
<p className="text-sm text-black dark:text-white mb-2">
Description
</p>
<p className="text-black dark:text-white mb-2">Description</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
@@ -253,14 +248,14 @@ export default function AddOrEditLink({
</div>
) : undefined}
<div className="flex justify-between items-center mt-2">
<div className="flex justify-between items-stretch mt-2">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`${
method === "UPDATE" ? "hidden" : ""
} rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 py-1 px-2 w-fit text-sm`}
} rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 flex items-center px-2 w-fit text-sm`}
>
{optionsExpanded ? "Hide" : "More"} Options
<p>{optionsExpanded ? "Hide" : "More"} Options</p>
</div>
<SubmitButton
-326
View File
@@ -1,326 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faBoxArchive,
faCloudArrowDown,
faFolder,
faGlobe,
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import {
faCalendarDays,
faFileImage,
faFilePdf,
} from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/client/isValidUrl";
import { useTheme } from "next-themes";
import unescapeString from "@/lib/client/unescapeString";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
isOwnerOrMod: boolean;
};
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",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
const { collections } = useCollectionStore();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections]);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const colorThief = new ColorThief();
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const rgbToHex = (r: number, g: number, b: number): string =>
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("");
useEffect(() => {
const banner = document.getElementById("link-banner");
const bannerInner = document.getElementById("link-banner-inner");
if (colorPalette && banner && bannerInner) {
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]
)})`;
}
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, theme]);
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/archives/${link.collection.id}/${link.id}.${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === "pdf" ? "PDF" : "Screenshot";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<div
className={`flex flex-col gap-3 sm:w-[35rem] w-80 ${
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>
</div>
)}
<div
className={`relative flex gap-5 items-start ${!imageError && "-mt-24"}`}
>
{!imageError && url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={42}
height={42}
alt=""
id={"favicon-" + link.id}
className="select-none mt-2 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 {
const color = colorThief.getPalette(
e.target as HTMLImageElement,
4
);
setColorPalette(color);
} catch (err) {
console.log(err);
}
}}
onError={(e) => {
setImageError(true);
}}
/>
)}
<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"
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>
</div>
</div>
<div className="flex gap-1 items-center flex-wrap">
<Link
href={`/collections/${link.collection.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
<FontAwesomeIcon
icon={faFolder}
className="w-5 h-5 drop-shadow"
style={{ color: collection?.color }}
/>
<p
title={collection?.name}
className="text-black dark:text-white text-lg truncate max-w-[12rem]"
>
{collection?.name}
</p>
</Link>
{link.tags.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="px-2 py-1 bg-sky-200 text-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>
</Link>
))}
</div>
{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 dark:text-gray-300">
<FontAwesomeIcon icon={faBoxArchive} className="w-4 h-4" />
<p>Archived Formats:</p>
</div>
<div
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" />
<p>{formattedDate}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">Screenshot</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover: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"
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={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">PDF</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover: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}.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>
);
}
+206
View File
@@ -0,0 +1,206 @@
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faCloudArrowDown,
} from "@fortawesome/free-solid-svg-icons";
import { faFileImage, faFilePdf } from "@fortawesome/free-regular-svg-icons";
import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
export default function PreservedFormats() {
const session = useSession();
const { links, getLink } = useLinkStore();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
const router = useRouter();
useEffect(() => {
if (links) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
let interval: NodeJS.Timer | undefined;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
let isPublicRoute = router.pathname.startsWith("/public")
? true
: undefined;
interval = setInterval(
() => getLink(link.id as number, isPublicRoute),
5000
);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.screenshotPath, link?.pdfPath, link?.readabilityPath]);
const updateArchive = async () => {
const load = toast.loading("Sending request...");
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(`Link is being archived...`);
getLink(link?.id as number);
} else toast.error(data.response);
};
const handleDownload = (format: ArchivedFormat) => {
const path = `/api/v1/archives/${link?.id}?format=${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download =
format === ArchivedFormat.screenshot ? "Screenshot" : "PDF";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<div className={`flex flex-col gap-3 sm:w-[35rem] w-80 pt-3`}>
{link?.screenshotPath && link?.screenshotPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">Screenshot</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload(ArchivedFormat.screenshot)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300"
/>
</div>
<Link
href={`/api/v1/archives/${link?.id}?format=${ArchivedFormat.screenshot}`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</Link>
</div>
</div>
) : undefined}
{link?.pdfPath && link.pdfPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p className="text-black dark:text-white">PDF</p>
</div>
<div className="flex text-black dark:text-white gap-1">
<div
onClick={() => handleDownload(ArchivedFormat.pdf)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300"
/>
</div>
<Link
href={`/api/v1/archives/${link?.id}?format=${ArchivedFormat.pdf}`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</Link>
</div>
</div>
) : undefined}
<div className="flex flex-col-reverse sm:flex-row gap-5 items-center justify-center">
{link?.collection.ownerId === session.data?.user.id ? (
<div
className={`w-full text-center bg-sky-700 p-1 rounded-md cursor-pointer select-none hover:bg-sky-600 duration-100 ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "mt-3"
: ""
}`}
onClick={() => updateArchive()}
>
<p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Link)</p>
</div>
) : undefined}
<Link
href={`https://web.archive.org/web/${link?.url.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className={`text-gray-500 dark:text-gray-300 duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "sm:mt-3"
: ""
}`}
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-4 h-4"
/>
<p className="whitespace-nowrap">
View Latest Snapshot on archive.org
</p>
</Link>
</div>
</div>
);
}
+33 -59
View File
@@ -1,89 +1,63 @@
import { Tab } from "@headlessui/react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import AddOrEditLink from "./AddOrEditLink";
import LinkDetails from "./LinkDetails";
import PreservedFormats from "./PreservedFormats";
type Props =
| {
toggleLinkModal: Function;
method: "CREATE";
isOwnerOrMod?: boolean;
activeLink?: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
className?: string;
}
| {
toggleLinkModal: Function;
method: "UPDATE";
isOwnerOrMod: boolean;
activeLink: LinkIncludingShortenedCollectionAndTags;
defaultIndex?: number;
className?: string;
}
| {
toggleLinkModal: Function;
method: "FORMATS";
activeLink: LinkIncludingShortenedCollectionAndTags;
className?: string;
};
export default function LinkModal({
className,
defaultIndex,
toggleLinkModal,
isOwnerOrMod,
activeLink,
method,
}: Props) {
return (
<div className={className}>
<Tab.Group defaultIndex={defaultIndex}>
{method === "CREATE" && (
<p className="text-xl text-black dark:text-white text-center">
New Link
{method === "CREATE" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Create a 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-black dark:text-white">
{method === "UPDATE" && isOwnerOrMod && (
<>
<Tab
className={({ selected }) =>
selected
? "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
</Tab>
<Tab
className={({ selected }) =>
selected
? "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
</Tab>
</>
)}
</Tab.List>
<Tab.Panels>
{activeLink && method === "UPDATE" && (
<Tab.Panel>
<LinkDetails link={activeLink} isOwnerOrMod={isOwnerOrMod} />
</Tab.Panel>
)}
<AddOrEditLink toggleLinkModal={toggleLinkModal} method="CREATE" />
</>
) : undefined}
<Tab.Panel>
{activeLink && method === "UPDATE" ? (
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="UPDATE"
activeLink={activeLink}
/>
) : (
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="CREATE"
/>
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
{activeLink && method === "UPDATE" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">Edit Link</p>
<AddOrEditLink
toggleLinkModal={toggleLinkModal}
method="UPDATE"
activeLink={activeLink}
/>
</>
) : undefined}
{method === "FORMATS" ? (
<>
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
Preserved Formats
</p>
<PreservedFormats />
</>
) : undefined}
</div>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ export default function Modal({ toggleModal, className, children }: Props) {
<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={`m-auto ${className}`}
className={`m-auto ${className || ""}`}
>
<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
-2
View File
@@ -27,8 +27,6 @@ export default function ModalManagement() {
<LinkModal
toggleLinkModal={toggleModal}
method={modal.method}
isOwnerOrMod={modal.isOwnerOrMod as boolean}
defaultIndex={modal.defaultIndex}
activeLink={modal.active as LinkIncludingShortenedCollectionAndTags}
/>
</Modal>
+14 -5
View File
@@ -6,11 +6,13 @@ import Dropdown from "@/components/Dropdown";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import Sidebar from "@/components/Sidebar";
import { useRouter } from "next/router";
import Search from "@/components/Search";
import SearchBar from "@/components/SearchBar";
import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals";
import { useTheme } from "next-themes";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import ToggleDarkMode from "./ToggleDarkMode";
export default function Navbar() {
const { setModal } = useModalStore();
@@ -33,7 +35,11 @@ export default function Navbar() {
const [sidebar, setSidebar] = useState(false);
window.addEventListener("resize", () => setSidebar(false));
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);
@@ -51,7 +57,7 @@ export default function Navbar() {
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div>
<Search />
<SearchBar />
<div className="flex items-center gap-2">
<div
onClick={() => {
@@ -71,6 +77,9 @@ export default function Navbar() {
New Link
</span>
</div>
<ToggleDarkMode className="sm:flex hidden" />
<div className="relative">
<div
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"
@@ -78,13 +87,13 @@ export default function Navbar() {
id="profile-dropdown"
>
<ProfilePhoto
src={account.profilePic}
src={account.image ? account.image : undefined}
priority={true}
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
/>
<p
id="profile-dropdown"
className="font-bold text-black dark:text-white leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
className="text-black dark:text-white leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
>
{account.name}
</p>
+1 -1
View File
@@ -11,7 +11,7 @@ export default function NoLinksFound({ text }: Props) {
const { setModal } = useModalStore();
return (
<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">
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center 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>
+20 -25
View File
@@ -2,51 +2,46 @@ import React, { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/free-solid-svg-icons";
import Image from "next/image";
import avatarExists from "@/lib/client/avatarExists";
type Props = {
src: string;
src?: string;
className?: string;
emptyImage?: boolean;
status?: Function;
priority?: boolean;
};
export default function ProfilePhoto({
src,
className,
emptyImage,
status,
priority,
}: Props) {
const [error, setError] = useState<boolean>(emptyImage || true);
const checkAvatarExistence = async () => {
const canPass = await avatarExists(src);
setError(!canPass);
};
export default function ProfilePhoto({ src, className, priority }: Props) {
const [image, setImage] = useState("");
useEffect(() => {
if (src) checkAvatarExistence();
if (src && !src?.includes("base64"))
setImage(`/api/v1/${src.replace("uploads/", "").replace(".jpg", "")}`);
else if (!src) setImage("");
else {
setImage(src);
}
}, [src]);
status && status(error || !src);
}, [src, error]);
return error || !src ? (
return !image ? (
<div
className={`bg-sky-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}`}
className={`bg-sky-600 dark:bg-sky-600 text-white h-10 w-10 aspect-square shadow rounded-full border border-slate-200 dark:border-neutral-700 flex items-center justify-center ${
className || ""
}`}
>
<FontAwesomeIcon icon={faUser} className="w-1/2 h-1/2 aspect-square" />
</div>
) : (
<Image
alt=""
src={src}
src={image}
height={112}
width={112}
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}`}
draggable={false}
onError={() => setImage("")}
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 || ""
}`}
/>
);
}
-100
View File
@@ -1,100 +0,0 @@
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
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[];
}
type Props = {
link: LinksIncludingTags;
count: number;
};
export default function LinkCard({ link, count }: Props) {
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const formattedDate = new Date(
link.createdAt as unknown as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<a href={link.url} target="_blank" rel="noreferrer" className="rounded-3xl">
<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
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={42}
height={42}
alt=""
className="select-none mt-3 z-10 rounded-md shadow border-[3px] border-white bg-white"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={80}
height={80}
alt=""
className="blur-sm absolute left-2 opacity-40 select-none hidden sm:block"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
</>
)}
<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-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">
{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-black text-xs rounded-3xl cursor-pointer truncate max-w-[10rem]"
>
{e.name}
</p>
))}
</div>
</div>
<div className="flex gap-2 items-center flex-wrap mt-2">
<p className="text-gray-500">{formattedDate}</p>
<div className="text-black flex items-center gap-1">
<p>{url ? url.host : link.url}</p>
</div>
</div>
</div>
<div className="hidden sm:group-hover/item:block duration-100 text-slate-500">
<FontAwesomeIcon
icon={faChevronRight}
className="w-7 h-7 slide-right-with-fade"
/>
</div>
</div>
</div>
</a>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
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";
import { TagIncludingLinkCount } from "@/types/global";
import Link from "next/link";
interface LinksIncludingTags extends LinkType {
tags: TagIncludingLinkCount[];
}
type Props = {
link: LinksIncludingTags;
count: number;
};
export default function LinkCard({ link, count }: Props) {
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
const formattedDate = new Date(
link.createdAt as unknown as string
).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
return (
<div className="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-lg p-3 flex items-start relative gap-3 group/item">
<div className="flex justify-between items-end gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-center gap-2">
<p className="text-2xl">
{url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={30}
height={30}
alt=""
className="select-none z-10 rounded-md shadow border-[1px] border-white bg-white float-left mr-2"
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
)}
{unescapeString(link.name || link.description)}
</p>
</div>
<div className="flex gap-3 items-center flex-wrap my-2">
<div className="flex gap-1 items-center flex-wrap">
{link.tags.map((e, i) => (
<Link
href={"/public/collections/20?q=" + e.name}
key={i}
className="px-2 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}
</Link>
))}
</div>
</div>
<div className="flex gap-1 items-center flex-wrap text-sm text-gray-500 dark:text-gray-300">
<p>{formattedDate}</p>
<p>·</p>
<Link
href={url ? url.href : link.url}
target="_blank"
className="hover:opacity-50 duration-100 truncate w-52 sm:w-fit"
title={url ? url.href : link.url}
>
{url ? url.host : link.url}
</Link>
</div>
<div className="w-full">
{unescapeString(link.description)}{" "}
<Link
href={`/public/links/${link.id}`}
className="flex gap-1 items-center flex-wrap text-sm text-gray-500 dark:text-gray-300 hover:opacity-50 duration-100 min-w-fit float-right mt-1 ml-2"
>
<p>Read</p>
<FontAwesomeIcon
icon={faChevronRight}
className="w-3 h-3 mt-[0.15rem]"
/>
</Link>
</div>
</div>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
type Props = {
placeHolder?: string;
};
export default function PublicSearchBar({ placeHolder }: Props) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState<string>("");
useEffect(() => {
router.query.q
? setSearchQuery(decodeURIComponent(router.query.q as string))
: setSearchQuery("");
}, [router.query.q]);
return (
<div className="flex items-center relative group">
<label
htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md text-sky-500 dark:text-sky-500"
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="w-4 h-4" />
</label>
<input
id="search-box"
type="text"
placeholder={placeHolder}
value={searchQuery}
onChange={(e) => {
e.target.value.includes("%") &&
toast.error("The search query should not contain '%'.");
setSearchQuery(e.target.value.replace("%", ""));
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (!searchQuery) {
return router.push("/public/collections/" + router.query.id);
}
return router.push(
"/public/collections/" +
router.query.id +
"?q=" +
encodeURIComponent(searchQuery || "")
);
}
}}
className="border text-sm border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-8 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>
);
}
@@ -4,24 +4,17 @@ import { useState } from "react";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
export default function Search() {
export default function SearchBar() {
const router = useRouter();
const routeQuery = router.query.query;
const routeQuery = router.query.q;
const [searchQuery, setSearchQuery] = useState(
routeQuery ? decodeURIComponent(routeQuery as string) : ""
);
const [searchBox, setSearchBox] = useState(
router.pathname.startsWith("/search") || false
);
return (
<div
className="flex items-center relative group"
onClick={() => setSearchBox(true)}
>
<div className="flex items-center relative group">
<label
htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md p-1 text-sky-500 dark:text-sky-500"
@@ -41,9 +34,8 @@ export default function Search() {
}}
onKeyDown={(e) =>
e.key === "Enter" &&
router.push("/search/" + encodeURIComponent(searchQuery))
router.push("/search?q=" + encodeURIComponent(searchQuery))
}
autoFocus={searchBox}
className="border border-sky-100 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>
+51 -22
View File
@@ -5,6 +5,7 @@ import {
faPalette,
faBoxArchive,
faKey,
faLock,
} from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -20,6 +21,8 @@ import {
} from "@fortawesome/free-brands-svg-icons";
export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.3.0";
const { collections } = useCollectionStore();
const router = useRouter();
@@ -32,16 +35,18 @@ export default function SettingsSidebar({ className }: { className?: string }) {
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}`}
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`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faUser}
@@ -58,9 +63,9 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<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`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faPalette}
@@ -77,9 +82,9 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<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`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faBoxArchive}
@@ -92,16 +97,33 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div>
</Link>
<Link href="/settings/api">
<div
className={`${
active === `/settings/api` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faKey}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7">
API Keys
</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`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faKey}
icon={faLock}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
@@ -111,14 +133,14 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div>
</Link>
{process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE ? (
{process.env.NEXT_PUBLIC_STRIPE ? (
<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`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faCreditCard}
@@ -134,9 +156,16 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div>
<div className="flex flex-col gap-1">
<Link
href={`https://github.com/linkwarden/linkwarden/releases`}
target="_blank"
className="dark:text-gray-300 text-gray-500 text-sm ml-2 hover:opacity-50 duration-100"
>
Linkwarden {LINKWARDEN_VERSION}
</Link>
<Link href="https://docs.linkwarden.app" target="_blank">
<div
className={`hover:bg-slate-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`}
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faCircleQuestion as any}
@@ -151,7 +180,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<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`}
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faGithub as any}
@@ -166,7 +195,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<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`}
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faXTwitter as any}
@@ -181,7 +210,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<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`}
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faMastodon as any}
+84 -57
View File
@@ -6,6 +6,8 @@ import {
faChartSimple,
faChevronDown,
faLink,
faGlobe,
faThumbTack,
} from "@fortawesome/free-solid-svg-icons";
import useTagStore from "@/store/tags";
import Link from "next/link";
@@ -50,61 +52,73 @@ export default function Sidebar({ className }: { className?: string }) {
return (
<div
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}`}
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 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 dark:text-sky-500`}
/>
<p className="text-black dark:text-white text-xs xl:text-sm font-semibold">
Dashboard
</p>
<div className="flex flex-col gap-2 mt-2">
<Link href={`/dashboard`}>
<div
className={`${
active === `/dashboard` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faChartSimple}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full">
Dashboard
</p>
</div>
</Link>
<Link
href="/links"
className={`${
active === "/links"
? "bg-sky-200 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 dark:text-sky-500`}
/>
<p className="text-black dark:text-white text-xs xl:text-sm font-semibold">
Links
</p>
<Link href={`/links`}>
<div
className={`${
active === `/links` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faLink}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full">
All Links
</p>
</div>
</Link>
<Link
href="/collections"
className={`${
active === "/collections"
? "bg-sky-200 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 dark:text-sky-500`}
/>
<Link href={`/collections`}>
<div
className={`${
active === `/collections` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faFolder}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full">
All Collections
</p>
</div>
</Link>
<p className="text-black dark:text-white text-xs xl:text-sm font-semibold">
Collections
</p>
<Link href={`/links/pinned`}>
<div
className={`${
active === `/links/pinned` ? "bg-sky-500" : "hover:bg-slate-500"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faThumbTack}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full">
Pinned Links
</p>
</div>
</Link>
</div>
@@ -142,19 +156,29 @@ export default function Sidebar({ className }: { className?: string }) {
<div
className={`${
active === `/collections/${e.id}`
? "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`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-1 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<FontAwesomeIcon
icon={faFolder}
className="w-6 h-6 drop-shadow"
style={{ color: e.color }}
/>
<p className="text-black dark:text-white truncate w-full pr-7">
<p className="text-black dark:text-white truncate w-full">
{e.name}
</p>
{e.isPublic ? (
<FontAwesomeIcon
icon={faGlobe}
title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
/>
) : undefined}
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs">
{e._count?.links}
</div>
</div>
</Link>
);
@@ -202,9 +226,9 @@ export default function Sidebar({ className }: { className?: string }) {
<div
className={`${
active === `/tags/${e.id}`
? "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`}
? "bg-sky-500"
: "hover:bg-slate-500"
} duration-100 py-1 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faHashtag}
@@ -214,6 +238,9 @@ export default function Sidebar({ className }: { className?: string }) {
<p className="text-black dark:text-white truncate w-full pr-7">
{e.name}
</p>
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs">
{e._count?.links}
</div>
</div>
</Link>
);
+1 -1
View File
@@ -21,7 +21,7 @@ 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 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-48"
className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-52"
>
<p className="mb-2 text-black dark:text-white text-center font-semibold">
Sort by
+10 -7
View File
@@ -2,11 +2,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
type Props = {
onClick: Function;
onClick?: Function;
icon?: IconProp;
label: string;
loading: boolean;
className?: string;
type?: "button" | "submit" | "reset" | undefined;
};
export default function SubmitButton({
@@ -15,20 +16,22 @@ export default function SubmitButton({
label,
loading,
className,
type,
}: Props) {
return (
<div
<button
type={type ? type : undefined}
className={`text-white flex items-center gap-2 py-2 px-5 rounded-md text-lg tracking-wide select-none font-semibold duration-100 w-fit ${
loading
? "bg-sky-600 cursor-auto"
: "bg-sky-700 hover:bg-sky-600 cursor-pointer"
} ${className}`}
} ${className || ""}`}
onClick={() => {
if (!loading) onClick();
if (!loading && onClick) onClick();
}}
>
{icon && <FontAwesomeIcon icon={icon} className="h-5 select-none" />}
<p className="text-center w-full select-none">{label}</p>
</div>
{icon && <FontAwesomeIcon icon={icon} className="h-5" />}
<p className="text-center w-full">{label}</p>
</button>
);
}
+3 -1
View File
@@ -27,7 +27,9 @@ export default function TextInput({
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}`}
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 || ""
}`}
/>
);
}
+10 -8
View File
@@ -2,7 +2,11 @@ import { useTheme } from "next-themes";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
export default function ToggleDarkMode() {
type Props = {
className?: string;
};
export default function ToggleDarkMode({ className }: Props) {
const { theme, setTheme } = useTheme();
const handleToggle = () => {
@@ -15,15 +19,13 @@ export default function ToggleDarkMode() {
return (
<div
className="flex gap-1 duration-100 h-10 rounded-full items-center w-fit cursor-pointer"
className={`cursor-pointer flex select-none border border-sky-600 items-center justify-center dark:bg-neutral-900 bg-white hover:border-sky-500 group duration-100 rounded-full text-white w-10 h-10 ${className}`}
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>
<FontAwesomeIcon
icon={theme === "dark" ? faSun : faMoon}
className="w-1/2 h-1/2 text-sky-600 group-hover:text-sky-500"
/>
</div>
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
version: "3.5"
services:
postgres:
image: postgres
image: postgres:16-alpine
env_file: .env
restart: always
volumes:
+11 -8
View File
@@ -2,7 +2,6 @@ import useCollectionStore from "@/store/collections";
import { useEffect } from "react";
import { useSession } from "next-auth/react";
import useTagStore from "@/store/tags";
import useLinkStore from "@/store/links";
import useAccountStore from "@/store/account";
export default function useInitialData() {
@@ -10,17 +9,21 @@ export default function useInitialData() {
const { setCollections } = useCollectionStore();
const { setTags } = useTagStore();
// const { setLinks } = useLinkStore();
const { setAccount } = useAccountStore();
const { account, setAccount } = useAccountStore();
// Get account info
useEffect(() => {
if (
status === "authenticated" &&
(!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE || data.user.isSubscriber)
) {
if (status === "authenticated") {
setAccount(data?.user.id as number);
}
}, [status, data]);
// Get the rest of the data
useEffect(() => {
if (account.id && (!process.env.NEXT_PUBLIC_STRIPE || account.username)) {
setCollections();
setTags();
// setLinks();
setAccount(data.user.id);
}
}, [status]);
}, [account]);
}
+48 -13
View File
@@ -7,11 +7,15 @@ import useLinkStore from "@/store/links";
export default function useLinks(
{
sort,
searchFilter,
searchQuery,
pinnedOnly,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
}: LinkRequestQuery = { sort: 0 }
) {
const { links, setLinks, resetLinks } = useLinkStore();
@@ -20,21 +24,43 @@ export default function useLinks(
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
const requestBody: LinkRequestQuery = {
cursor,
const params = {
sort,
searchFilter,
searchQuery,
pinnedOnly,
cursor,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
};
const encodedData = encodeURIComponent(JSON.stringify(requestBody));
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
const response = await fetch(
`/api/links?body=${encodeURIComponent(encodedData)}`
);
let queryString = buildQueryString(params);
let basePath;
if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard";
else if (router.pathname.startsWith("/public/collections/[id]")) {
queryString = queryString + "&collectionId=" + router.query.id;
basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links";
const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json();
@@ -45,7 +71,16 @@ export default function useLinks(
resetLinks();
getLinks(true);
}, [router, sort, searchFilter]);
}, [
router,
sort,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTextContent,
searchByTags,
]);
useEffect(() => {
if (reachedBottom) getLinks(false, links?.at(-1)?.id);
-30
View File
@@ -1,30 +0,0 @@
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
export default function useRedirect() {
const router = useRouter();
const { status } = useSession();
const [redirect, setRedirect] = useState(true);
useEffect(() => {
if (
status === "authenticated" &&
(router.pathname === "/login" || router.pathname === "/register")
) {
router.push("/").then(() => {
setRedirect(false);
});
} else if (
status === "unauthenticated" &&
!(router.pathname === "/login" || router.pathname === "/register")
) {
router.push("/login").then(() => {
setRedirect(false);
});
} else if (status === "loading") setRedirect(true);
else setRedirect(false);
}, [status]);
return redirect;
}
+25
View File
@@ -0,0 +1,25 @@
import React, { useState, useEffect } from "react";
export default function useWindowDimensions() {
const [dimensions, setDimensions] = useState({
height: window.innerHeight,
width: window.innerWidth,
});
useEffect(() => {
function handleResize() {
setDimensions({
height: window.innerHeight,
width: window.innerWidth,
});
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return dimensions;
}
+24 -14
View File
@@ -4,6 +4,7 @@ import Loader from "../components/Loader";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useInitialData from "@/hooks/useInitialData";
import useAccountStore from "@/store/account";
interface Props {
children: ReactNode;
@@ -13,40 +14,49 @@ export default function AuthRedirect({ children }: Props) {
const router = useRouter();
const { status, data } = useSession();
const [redirect, setRedirect] = useState(true);
const { account } = useAccountStore();
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
useInitialData();
useEffect(() => {
if (!router.pathname.startsWith("/public")) {
if (
status === "authenticated" &&
account.id &&
!account.subscription?.active &&
stripeEnabled
) {
router.push("/subscribe").then(() => {
setRedirect(false);
});
}
// Redirect to "/choose-username" if user is authenticated and is either a subscriber OR subscription is undefiend, and doesn't have a username
else if (
emailEnabled &&
status === "authenticated" &&
(data.user.isSubscriber === true ||
data.user.isSubscriber === undefined) &&
!data.user.username
account.subscription?.active &&
stripeEnabled &&
account.id &&
!account.username
) {
router.push("/choose-username").then(() => {
setRedirect(false);
});
} else if (
status === "authenticated" &&
data.user.isSubscriber === false
) {
router.push("/subscribe").then(() => {
setRedirect(false);
});
} else if (
status === "authenticated" &&
account.id &&
(router.pathname === "/login" ||
router.pathname === "/register" ||
router.pathname === "/confirmation" ||
router.pathname === "/subscribe" ||
router.pathname === "/choose-username" ||
router.pathname === "/forgot")
router.pathname === "/forgot" ||
router.pathname === "/")
) {
router.push("/").then(() => {
router.push("/dashboard").then(() => {
setRedirect(false);
});
} else if (
@@ -66,7 +76,7 @@ export default function AuthRedirect({ children }: Props) {
} else {
setRedirect(false);
}
}, [status]);
}, [status, account, router.pathname]);
if (status !== "loading" && !redirect) return <>{children}</>;
else return <></>;
+14 -4
View File
@@ -10,10 +10,20 @@ interface Props {
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" ? (
<div className="m-auto flex flex-col gap-2 w-full">
{theme ? (
<Image
src={`/linkwarden_${theme === "dark" ? "dark" : "light"}.png`}
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
) : undefined}
{/* {theme === "dark" ? (
<Image
src="/linkwarden_dark.png"
width={640}
@@ -29,9 +39,9 @@ export default function CenteredForm({ text, children }: Props) {
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">
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold text-black dark:text-white px-2 text-center">
{text}
</p>
) : undefined}
+207
View File
@@ -0,0 +1,207 @@
import LinkSidebar from "@/components/LinkSidebar";
import { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
import { useRouter } from "next/router";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import useWindowDimensions from "@/hooks/useWindowDimensions";
import {
faPen,
faBoxesStacked,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import useLinkStore from "@/store/links";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections";
interface Props {
children: ReactNode;
}
export default function LinkLayout({ children }: Props) {
const { modal } = useModalStore();
const router = useRouter();
useEffect(() => {
modal
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "auto");
}, [modal]);
const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => {
setSidebar(!sidebar);
};
const session = useSession();
const userId = session.data?.user.id;
const { setModal } = useModalStore();
const { links, removeLink } = useLinkStore();
const { collections } = useCollectionStore();
const [linkCollection, setLinkCollection] =
useState<CollectionIncludingMembersAndLinkCount>();
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>();
useEffect(() => {
if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id)));
}, [links]);
useEffect(() => {
if (link)
setLinkCollection(collections.find((e) => e.id === link?.collection?.id));
}, [link, collections]);
return (
<>
<ModalManagement />
<div className="flex mx-auto">
{/* <div className="hidden lg:block fixed left-5 h-screen">
<LinkSidebar />
</div> */}
<div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5">
<div className="flex gap-3 mb-5 duration-100 items-center justify-between">
{/* <div
onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
>
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div> */}
<div
onClick={() => {
if (router.pathname.startsWith("/public")) {
router.push(
`/public/collections/${
linkCollection?.id || link?.collection.id
}`
);
} else {
router.push(`/dashboard`);
}
}}
className="inline-flex gap-1 hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100"
>
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back{" "}
<span className="hidden sm:inline-block">
to{" "}
<span className="capitalize">
{router.pathname.startsWith("/public")
? linkCollection?.name || link?.collection?.name
: "Dashboard"}
</span>
</span>
</div>
<div className="flex gap-5">
{link?.collection?.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate
) ? (
<div
title="Edit"
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "UPDATE",
})
: undefined;
}}
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
) : undefined}
<div
onClick={() => {
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined;
}}
title="Preserved Formats"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
{link?.collection?.ownerId === userId ||
linkCollection?.members.some(
(e) => e.userId === userId && e.canDelete
) ? (
<div
onClick={() => {
if (link?.id) {
removeLink(link.id);
router.back();
}
}}
title="Delete"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
>
<FontAwesomeIcon
icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
</div>
) : undefined}
</div>
</div>
{children}
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
onClickOutside={toggleSidebar}
>
<div className="slide-right h-full shadow-lg">
<LinkSidebar onClick={() => setSidebar(false)} />
</div>
</ClickAwayHandler>
</div>
) : null}
</div>
</div>
</>
);
}
+38 -3
View File
@@ -1,8 +1,10 @@
import Navbar from "@/components/Navbar";
import AnnouncementBar from "@/components/AnnouncementBar";
import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect } from "react";
import { ReactNode, useEffect, useState } from "react";
import ModalManagement from "@/components/ModalManagement";
import useModalStore from "@/store/modals";
import getLatestVersion from "@/lib/client/getLatestVersion";
interface Props {
children: ReactNode;
@@ -17,16 +19,49 @@ export default function MainLayout({ children }: Props) {
: (document.body.style.overflow = "auto");
}, [modal]);
const showAnnouncementBar = localStorage.getItem("showAnnouncementBar");
const [showAnnouncement, setShowAnnouncement] = useState(
showAnnouncementBar ? showAnnouncementBar === "true" : true
);
useEffect(() => {
getLatestVersion(setShowAnnouncement);
}, []);
useEffect(() => {
if (showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "true");
setShowAnnouncement(true);
} else if (!showAnnouncement) {
localStorage.setItem("showAnnouncementBar", "false");
setShowAnnouncement(false);
}
}, [showAnnouncement]);
const toggleAnnouncementBar = () => {
setShowAnnouncement(!showAnnouncement);
};
return (
<>
<ModalManagement />
{showAnnouncement ? (
<AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} />
) : undefined}
<div className="flex">
<div className="hidden lg:block">
<Sidebar className="fixed top-0" />
<Sidebar
className={`fixed ${showAnnouncement ? "top-10" : "top-0"}`}
/>
</div>
<div className="w-full flex flex-col h-screen lg:ml-64 xl:ml-80">
<div
className={`w-full flex flex-col min-h-${
showAnnouncement ? "full" : "screen"
} lg:ml-64 xl:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
>
<Navbar />
{children}
</div>
+9 -10
View File
@@ -7,6 +7,7 @@ import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link";
import useWindowDimensions from "@/hooks/useWindowDimensions";
interface Props {
children: ReactNode;
@@ -25,7 +26,11 @@ export default function SettingsLayout({ children }: Props) {
const [sidebar, setSidebar] = useState(false);
window.addEventListener("resize", () => setSidebar(false));
const { width } = useWindowDimensions();
useEffect(() => {
setSidebar(false);
}, [width]);
useEffect(() => {
setSidebar(false);
@@ -44,8 +49,8 @@ export default function SettingsLayout({ children }: Props) {
<SettingsSidebar />
</div>
<div className="w-full flex flex-col min-h-screen p-5 lg:ml-64">
<div className="flex gap-3">
<div className="w-full min-h-screen p-5 lg:ml-64">
<div className="gap-2 inline-flex mr-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"
@@ -55,18 +60,12 @@ export default function SettingsLayout({ children }: Props) {
<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"
className="inline-flex w-fit 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 ? (
+75 -24
View File
@@ -2,18 +2,29 @@ import { chromium, devices } from "playwright";
import { prisma } from "@/lib/api/db";
import createFile from "@/lib/api/storage/createFile";
import sendToWayback from "./sendToWayback";
import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom";
import DOMPurify from "dompurify";
export default async function archive(
linkId: number,
url: string,
userId: number
) {
const user = await prisma.user.findUnique({
where: {
id: userId,
const user = await prisma.user.findUnique({ where: { id: userId } });
const targetLink = await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: user?.archiveAsScreenshot ? "pending" : null,
pdfPath: user?.archiveAsPDF ? "pending" : null,
readabilityPath: "pending",
lastPreserved: new Date().toISOString(),
},
});
// Archive.org
if (user?.archiveAsWaybackMachine) sendToWayback(url);
if (user?.archiveAsPDF || user?.archiveAsScreenshot) {
@@ -24,24 +35,48 @@ export default async function archive(
try {
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.evaluate(
autoScroll,
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
);
const content = await page.content();
const linkExists = await prisma.link.findUnique({
where: {
id: linkId,
// Readability
const window = new JSDOM("").window;
const purify = DOMPurify(window);
const cleanedUpContent = purify.sanitize(content);
const dom = new JSDOM(cleanedUpContent, { url: url });
const article = new Readability(dom.window.document).parse();
const articleText = article?.textContent
.replace(/ +(?= )/g, "") // strip out multiple spaces
.replace(/(\r\n|\n|\r)/gm, " "); // strip out line breaks
await createFile({
data: JSON.stringify(article),
filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
});
await prisma.link.update({
where: { id: linkId },
data: {
readabilityPath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
textContent: articleText,
},
});
if (linkExists) {
if (user.archiveAsScreenshot) {
const screenshot = await page.screenshot({
fullPage: true,
});
// Screenshot/PDF
createFile({
let faulty = false;
await page
.evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30)
.catch((e) => (faulty = true));
const linkExists = await prisma.link.findUnique({
where: { id: linkId },
});
if (linkExists && !faulty) {
if (user.archiveAsScreenshot) {
const screenshot = await page.screenshot({ fullPage: true });
await createFile({
data: screenshot,
filePath: `archives/${linkExists.collectionId}/${linkId}.png`,
});
@@ -55,16 +90,36 @@ export default async function archive(
margin: { top: "15px", bottom: "15px" },
});
createFile({
await createFile({
data: pdf,
filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`,
});
}
}
await browser.close();
await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: user.archiveAsScreenshot
? `archives/${linkExists.collectionId}/${linkId}.png`
: null,
pdfPath: user.archiveAsPDF
? `archives/${linkExists.collectionId}/${linkId}.pdf`
: null,
},
});
} else if (faulty) {
await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: null,
pdfPath: null,
},
});
}
} catch (err) {
console.log(err);
throw err;
} finally {
await browser.close();
}
}
@@ -73,11 +128,7 @@ export default async function archive(
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).`
)
);
reject(new Error(`Webpage was too long to be archived.`));
}, AUTOSCROLL_TIMEOUT * 1000);
});
-49
View File
@@ -1,49 +0,0 @@
import Stripe from "stripe";
export default async function checkSubscription(
stripeSecretKey: string,
email: string
) {
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2022-11-15",
});
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
});
let subscriptionCanceledAt: number | null | undefined;
const isSubscriber = listByEmail.data.some((customer, i) => {
const hasValidSubscription = customer.subscriptions?.data.some(
(subscription) => {
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
const secondsInTwoWeeks = NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS) * 86400
: 1209600;
subscriptionCanceledAt = subscription.canceled_at;
const isNotCanceledOrHasTime = !(
subscription.canceled_at &&
new Date() >
new Date((subscription.canceled_at + secondsInTwoWeeks) * 1000)
);
return subscription?.items?.data[0].plan && isNotCanceledOrHasTime;
}
);
return (
customer.email?.toLowerCase() === email.toLowerCase() &&
hasValidSubscription
);
});
return {
isSubscriber,
subscriptionCanceledAt,
};
}
+52
View File
@@ -0,0 +1,52 @@
import Stripe from "stripe";
const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID;
const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID;
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function checkSubscriptionByEmail(email: string) {
let active: boolean | undefined,
stripeSubscriptionId: string | undefined,
currentPeriodStart: number | undefined,
currentPeriodEnd: number | undefined;
if (!STRIPE_SECRET_KEY)
return {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
};
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
console.log("Request made to Stripe by:", email);
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
});
listByEmail.data.some((customer) => {
customer.subscriptions?.data.some((subscription) => {
subscription.current_period_end;
active = subscription.items.data.some(
(e) =>
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
);
stripeSubscriptionId = subscription.id;
currentPeriodStart = subscription.current_period_start * 1000;
currentPeriodEnd = subscription.current_period_end * 1000;
});
});
return {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
};
}
@@ -4,19 +4,16 @@ import { Collection, UsersAndCollections } from "@prisma/client";
import removeFolder from "@/lib/api/storage/removeFolder";
export default async function deleteCollection(
collection: { id: number },
userId: number
userId: number,
collectionId: number
) {
const collectionId = collection.id;
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const collectionIsAccessible = (await getPermission(userId, collectionId)) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
const collectionIsAccessible = await getPermission({
userId,
collectionId,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
@@ -4,20 +4,17 @@ import getPermission from "@/lib/api/getPermission";
import { Collection, UsersAndCollections } from "@prisma/client";
export default async function updateCollection(
collection: CollectionIncludingMembersAndLinkCount,
userId: number
userId: number,
collectionId: number,
data: CollectionIncludingMembersAndLinkCount
) {
if (!collection.id)
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const collectionIsAccessible = (await getPermission(
const collectionIsAccessible = await getPermission({
userId,
collection.id
)) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
collectionId,
});
if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 };
@@ -26,23 +23,23 @@ export default async function updateCollection(
await prisma.usersAndCollections.deleteMany({
where: {
collection: {
id: collection.id,
id: collectionId,
},
},
});
return await prisma.collection.update({
where: {
id: collection.id,
id: collectionId,
},
data: {
name: collection.name.trim(),
description: collection.description,
color: collection.color,
isPublic: collection.isPublic,
name: data.name.trim(),
description: data.description,
color: data.color,
isPublic: data.isPublic,
members: {
create: collection.members.map((e) => ({
create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } },
canCreate: e.canCreate,
canUpdate: e.canUpdate,
@@ -58,6 +55,7 @@ export default async function updateCollection(
include: {
user: {
select: {
image: true,
username: true,
name: true,
id: true,
@@ -18,6 +18,7 @@ export default async function getCollection(userId: number) {
select: {
username: true,
name: true,
image: true,
},
},
},
@@ -0,0 +1,78 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getDashboardData(
userId: number,
query: LinkRequestQuery
) {
let order: any;
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
else if (query.sort === Sort.NameZA) order = { name: "desc" };
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
const pinnedLinks = await prisma.link.findMany({
take: 6,
where: {
AND: [
{
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
{
pinnedBy: { some: { id: userId } },
},
],
},
include: {
tags: true,
collection: true,
pinnedBy: {
where: { id: userId },
select: { id: true },
},
},
orderBy: order || { createdAt: "desc" },
});
const recentlyAddedLinks = await prisma.link.findMany({
take: 6,
where: {
collection: {
OR: [
{ ownerId: userId },
{
members: {
some: { userId },
},
},
],
},
},
include: {
tags: true,
collection: true,
pinnedBy: {
where: { id: userId },
select: { id: true },
},
},
orderBy: order || { createdAt: "desc" },
});
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
(a, b) => (new Date(b.createdAt) as any) - (new Date(a.createdAt) as any)
);
return { response: links, status: 200 };
}
-40
View File
@@ -1,40 +0,0 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile";
export default async function deleteLink(
link: LinkIncludingShortenedCollectionAndTags,
userId: number
) {
if (!link || !link.collectionId)
return { response: "Please choose a valid link.", status: 401 };
const collectionIsAccessible = (await getPermission(
userId,
link.collectionId
)) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canDelete
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
const deleteLink: Link = await prisma.link.delete({
where: {
id: link.id,
},
});
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
return { response: deleteLink, status: 200 };
}
+20 -13
View File
@@ -1,9 +1,7 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(userId: number, body: string) {
const query: LinkRequestQuery = JSON.parse(decodeURIComponent(body));
export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
let order: any;
@@ -16,40 +14,49 @@ export default async function getLink(userId: number, body: string) {
const searchConditions = [];
if (query.searchQuery) {
if (query.searchFilter?.name) {
if (query.searchQueryString) {
if (query.searchByName) {
searchConditions.push({
name: {
contains: query.searchQuery,
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.url) {
if (query.searchByUrl) {
searchConditions.push({
url: {
contains: query.searchQuery,
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.description) {
if (query.searchByDescription) {
searchConditions.push({
description: {
contains: query.searchQuery,
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchFilter?.tags) {
if (query.searchByTextContent) {
searchConditions.push({
textContent: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByTags) {
searchConditions.push({
tags: {
some: {
name: {
contains: query.searchQuery,
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
OR: [
@@ -117,7 +124,7 @@ export default async function getLink(userId: number, body: string) {
OR: [
...tagCondition,
{
[query.searchQuery ? "OR" : "AND"]: [
[query.searchQueryString ? "OR" : "AND"]: [
{
pinnedBy: query.pinnedOnly
? { some: { id: userId } }
@@ -0,0 +1,35 @@
import { prisma } from "@/lib/api/db";
import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile";
export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
const collectionIsAccessible = await getPermission({ userId, linkId });
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canDelete
);
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
const deleteLink: Link = await prisma.link.delete({
where: {
id: linkId,
},
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
return { response: deleteLink, status: 200 };
}
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/api/db";
import { Collection, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
export default async function getLinkById(userId: number, linkId: number) {
if (!linkId)
return {
response: "Please choose a valid link.",
status: 401,
};
const collectionIsAccessible = await getPermission({ userId, linkId });
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId
);
const isCollectionOwner = collectionIsAccessible?.ownerId === userId;
if (collectionIsAccessible?.ownerId !== userId && !memberHasAccess)
return {
response: "Collection is not accessible.",
status: 401,
};
else {
const link = await prisma.link.findUnique({
where: {
id: linkId,
},
include: {
tags: true,
collection: true,
pinnedBy: isCollectionOwner
? {
where: { id: userId },
select: { id: true },
}
: undefined,
},
});
return { response: link, status: 200 };
}
}
@@ -4,41 +4,29 @@ 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
export default async function updateLinkById(
userId: number,
linkId: number,
data: LinkIncludingShortenedCollectionAndTags
) {
console.log(link);
if (!link || !link.collection.id)
if (!data || !data.collection.id)
return {
response: "Please choose a valid link and collection.",
status: 401,
};
const targetLink = (await getPermission(
userId,
link.collection.id,
link.id
)) as
| (Link & {
collection: Collection & {
members: UsersAndCollections[];
};
})
| null;
const collectionIsAccessible = await getPermission({ userId, linkId });
const memberHasAccess = targetLink?.collection.members.some(
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canUpdate
);
const isCollectionOwner =
targetLink?.collection.ownerId === link.collection.ownerId &&
link.collection.ownerId === userId;
collectionIsAccessible?.ownerId === data.collection.ownerId &&
data.collection.ownerId === userId;
const unauthorizedSwitchCollection =
!isCollectionOwner && targetLink?.collection.id !== link.collection.id;
console.log(isCollectionOwner);
!isCollectionOwner && collectionIsAccessible?.id !== data.collection.id;
// Makes sure collection members (non-owners) cannot move a link to/from a collection.
if (unauthorizedSwitchCollection)
@@ -46,7 +34,7 @@ export default async function updateLink(
response: "You can't move a link to/from a collection you don't own.",
status: 401,
};
else if (targetLink?.collection.ownerId !== userId && !memberHasAccess)
else if (collectionIsAccessible?.ownerId !== userId && !memberHasAccess)
return {
response: "Collection is not accessible.",
status: 401,
@@ -54,37 +42,37 @@ export default async function updateLink(
else {
const updatedLink = await prisma.link.update({
where: {
id: link.id,
id: linkId,
},
data: {
name: link.name,
description: link.description,
name: data.name,
description: data.description,
collection: {
connect: {
id: link.collection.id,
id: data.collection.id,
},
},
tags: {
set: [],
connectOrCreate: link.tags.map((tag) => ({
connectOrCreate: data.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name,
ownerId: link.collection.ownerId,
ownerId: data.collection.ownerId,
},
},
create: {
name: tag.name,
owner: {
connect: {
id: link.collection.ownerId,
id: data.collection.ownerId,
},
},
},
})),
},
pinnedBy:
link?.pinnedBy && link.pinnedBy[0]
data?.pinnedBy && data.pinnedBy[0]
? { connect: { id: userId } }
: { disconnect: { id: userId } },
},
@@ -100,15 +88,20 @@ export default async function updateLink(
},
});
if (targetLink?.collection.id !== link.collection.id) {
if (collectionIsAccessible?.id !== data.collection.id) {
await moveFile(
`archives/${targetLink?.collection.id}/${link.id}.pdf`,
`archives/${link.collection.id}/${link.id}.pdf`
`archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
`archives/${data.collection.id}/${linkId}.pdf`
);
await moveFile(
`archives/${targetLink?.collection.id}/${link.id}.png`,
`archives/${link.collection.id}/${link.id}.png`
`archives/${collectionIsAccessible?.id}/${linkId}.png`,
`archives/${data.collection.id}/${linkId}.png`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
`archives/${data.collection.id}/${linkId}_readability.json`
);
}
+5 -8
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, UsersAndCollections } from "@prisma/client";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import createFolder from "@/lib/api/storage/createFolder";
@@ -27,14 +27,10 @@ export default async function postLink(
link.collection.name = link.collection.name.trim();
if (link.collection.id) {
const collectionIsAccessible = (await getPermission(
const collectionIsAccessible = await getPermission({
userId,
link.collection.id
)) as
| (Collection & {
members: UsersAndCollections[];
})
| null;
collectionId: link.collection.id,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === userId && e.canCreate
@@ -56,6 +52,7 @@ export default async function postLink(
url: link.url,
name: link.name,
description,
readabilityPath: "pending",
collection: {
connectOrCreate: {
where: {
+1 -1
View File
@@ -18,7 +18,7 @@ export default async function exportData(userId: number) {
if (!user) return { response: "User not found.", status: 404 };
const { password, id, image, ...userData } = user;
const { password, id, ...userData } = user;
function redactIds(obj: any) {
if (Array.isArray(obj)) {
@@ -7,94 +7,97 @@ export default async function importFromHTMLFile(
userId: number,
rawData: string
) {
try {
const dom = new JSDOM(rawData);
const document = dom.window.document;
const dom = new JSDOM(rawData);
const document = dom.window.document;
const folders = document.querySelectorAll("H3");
const folders = document.querySelectorAll("H3");
// @ts-ignore
for (const folder of folders) {
const findCollection = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collections: {
await prisma
.$transaction(
async () => {
// @ts-ignore
for (const folder of folders) {
const findCollection = await prisma.user.findUnique({
where: {
name: folder.textContent.trim(),
id: userId,
},
},
},
});
select: {
collections: {
where: {
name: folder.textContent.trim(),
},
},
},
});
const checkIfCollectionExists = findCollection?.collections[0];
const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
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,
},
});
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}` });
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
collectionId = newCollection.id;
}
createFolder({ filePath: `archives/${collectionId}` });
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,
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,
},
},
},
},
}
: undefined
),
}
: undefined,
description: bookmark.getAttribute("DESCRIPTION")
? bookmark.getAttribute("DESCRIPTION")
: "",
collectionId: collectionId,
createdAt: new Date(),
},
});
}
}
} catch (err) {
console.log(err);
}
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
},
},
},
}
: undefined
),
}
: undefined,
description: bookmark.getAttribute("DESCRIPTION")
? bookmark.getAttribute("DESCRIPTION")
: "",
collectionId: collectionId,
createdAt: new Date(),
},
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 };
}
@@ -5,87 +5,88 @@ 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);
await prisma
.$transaction(
async () => {
// Import collections
for (const e of data.collections) {
e.name = e.name.trim();
// 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: {
const findCollection = await prisma.user.findUnique({
where: {
name: e.name,
id: userId,
},
},
},
});
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) => ({
select: {
collections: {
where: {
name_ownerId: {
name: tag.name.trim(),
ownerId: userId,
},
name: e.name,
},
create: {
name: tag.name.trim(),
owner: {
connect: {
id: userId,
},
},
},
})),
},
},
},
});
}
}
} catch (err) {
console.log(err);
}
});
const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists) {
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
// Import Links
for (const link of e.links) {
const newLink = await prisma.link.create({
data: {
url: link.url,
name: link.name,
description: link.description,
collection: {
connect: {
id: collectionId,
},
},
// Import Tags
tags: {
connectOrCreate: link.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name.trim(),
ownerId: userId,
},
},
create: {
name: tag.name.trim(),
owner: {
connect: {
id: userId,
},
},
},
})),
},
},
});
}
}
},
{ timeout: 30000 }
)
.catch((err) => console.log(err));
return { response: "Success.", status: 200 };
}
@@ -0,0 +1,32 @@
import { prisma } from "@/lib/api/db";
export default async function getPublicCollection(id: number) {
const collection = await prisma.collection.findFirst({
where: {
id,
isPublic: true,
},
include: {
members: {
include: {
user: {
select: {
username: true,
name: true,
image: true,
},
},
},
},
_count: {
select: { links: true },
},
},
});
if (collection) {
return { response: collection, status: 200 };
} else {
return { response: "Collection not found.", status: 400 };
}
}
@@ -1,45 +0,0 @@
import { prisma } from "@/lib/api/db";
import { PublicLinkRequestQuery } from "@/types/global";
export default async function getCollection(body: string) {
const query: PublicLinkRequestQuery = JSON.parse(decodeURIComponent(body));
console.log(query);
let data;
const collection = await prisma.collection.findFirst({
where: {
id: query.collectionId,
isPublic: true,
},
});
if (collection) {
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,
where: {
collection: {
id: query.collectionId,
},
},
include: {
tags: true,
},
orderBy: {
createdAt: "desc",
},
});
data = { ...collection, links: [...links] };
return { response: data, status: 200 };
} else {
return { response: "Collection not found...", status: 400 };
}
}
@@ -0,0 +1,88 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">
) {
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 = { description: "asc" };
else if (query.sort === Sort.DescriptionZA) order = { description: "desc" };
const searchConditions = [];
if (query.searchQueryString) {
if (query.searchByName) {
searchConditions.push({
name: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByUrl) {
searchConditions.push({
url: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByDescription) {
searchConditions.push({
description: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByTextContent) {
searchConditions.push({
textContent: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
});
}
if (query.searchByTags) {
searchConditions.push({
tags: {
some: {
name: {
contains: query.searchQueryString,
mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
},
},
},
});
}
}
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,
where: {
collection: {
id: query.collectionId,
isPublic: true,
},
[query.searchQueryString ? "OR" : "AND"]: [...searchConditions],
},
include: {
tags: true,
},
orderBy: order || { createdAt: "desc" },
});
return { response: links, status: 200 };
}
@@ -0,0 +1,24 @@
import { prisma } from "@/lib/api/db";
export default async function getLinkById(linkId: number) {
if (!linkId)
return {
response: "Please choose a valid link.",
status: 401,
};
const link = await prisma.link.findFirst({
where: {
id: linkId,
collection: {
isPublic: true,
},
},
include: {
tags: true,
collection: true,
},
});
return { response: link, status: 200 };
}
@@ -0,0 +1,80 @@
import { prisma } from "@/lib/api/db";
export default async function getPublicUser(
targetId: number | string,
isId: boolean,
requestingId?: number
) {
const user = await prisma.user.findUnique({
where: isId
? {
id: Number(targetId) as number,
}
: {
username: targetId as string,
},
include: {
whitelistedUsers: {
select: {
username: true,
},
},
},
});
if (!user)
return { response: "User not found or profile is private.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(
(usernames) => usernames.username
);
const isInAPublicCollection = await prisma.collection.findFirst({
where: {
["OR"]: [
{ ownerId: user.id },
{
members: {
some: {
userId: user.id,
},
},
},
],
isPublic: true,
},
});
if (user?.isPrivate && !isInAPublicCollection) {
if (requestingId) {
const requestingUser = await prisma.user.findUnique({
where: { id: requestingId },
});
if (
requestingUser?.id !== requestingId &&
(!requestingUser?.username ||
!whitelistedUsernames.includes(
requestingUser.username?.toLowerCase()
))
) {
return {
response: "User not found or profile is private.",
status: 404,
};
}
} else
return { response: "User not found or profile is private.", status: 404 };
}
const { password, ...lessSensitiveInfo } = user;
const data = {
id: lessSensitiveInfo.id,
name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username,
image: lessSensitiveInfo.image,
};
return { response: data, status: 200 };
}
+5
View File
@@ -30,6 +30,11 @@ export default async function getTags(userId: number) {
},
],
},
include: {
_count: {
select: { links: true },
},
},
// orderBy: {
// links: {
// _count: "desc",
@@ -0,0 +1,26 @@
import { prisma } from "@/lib/api/db";
export default async function deleteTagById(userId: number, tagId: number) {
if (!tagId)
return { response: "Please choose a valid name for the tag.", status: 401 };
const targetTag = await prisma.tag.findUnique({
where: {
id: tagId,
},
});
if (targetTag?.ownerId !== userId)
return {
response: "Permission denied.",
status: 401,
};
const updatedTag = await prisma.tag.delete({
where: {
id: tagId,
},
});
return { response: updatedTag, status: 200 };
}
@@ -0,0 +1,47 @@
import { prisma } from "@/lib/api/db";
import { Tag } from "@prisma/client";
export default async function updeteTagById(
userId: number,
tagId: number,
data: Tag
) {
if (!tagId || !data.name)
return { response: "Please choose a valid name for the tag.", status: 401 };
const tagNameIsTaken = await prisma.tag.findFirst({
where: {
ownerId: userId,
name: data.name,
},
});
if (tagNameIsTaken)
return {
response: "Tag names should be unique.",
status: 400,
};
const targetTag = await prisma.tag.findUnique({
where: {
id: tagId,
},
});
if (targetTag?.ownerId !== userId)
return {
response: "Permission denied.",
status: 401,
};
const updatedTag = await prisma.tag.update({
where: {
id: tagId,
},
data: {
name: data.name,
},
});
return { response: updatedTag, status: 200 };
}
-54
View File
@@ -1,54 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getUser({
params,
isSelf,
username,
}: {
params: {
lookupUsername?: string;
lookupId?: number;
};
isSelf: boolean;
username: string;
}) {
const user = await prisma.user.findUnique({
where: {
id: params.lookupId,
username: params.lookupUsername?.toLowerCase(),
},
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 &&
!whitelistedUsernames.includes(username.toLowerCase())
) {
return { response: "This profile is private.", status: 401 };
}
const { password, ...lessSensitiveInfo } = user;
const data = isSelf
? // If user is requesting its own data
{...lessSensitiveInfo, whitelistedUsers: whitelistedUsernames}
: {
// If user is requesting someone elses data
id: lessSensitiveInfo.id,
name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username,
};
return { response: data || null, status: 200 };
}
@@ -16,7 +16,7 @@ interface User {
password: string;
}
export default async function Index(
export default async function postUser(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
@@ -30,13 +30,26 @@ export default async function Index(
? !body.password || !body.name || !body.email
: !body.username || !body.password || !body.name;
if (!body.password || body.password.length < 8)
return res
.status(400)
.json({ response: "Password must be at least 8 characters." });
if (checkHasEmptyFields)
return res
.status(400)
.json({ response: "Please fill out all the fields." });
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
// Check email (if enabled)
const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
if (emailEnabled && !checkEmail.test(body.email?.toLowerCase() || ""))
return res.status(400).json({
response: "Please enter a valid email.",
});
// Check username (if email was disabled)
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!emailEnabled && !checkUsername.test(body.username?.toLowerCase() || ""))
return res.status(400).json({
response:
@@ -46,11 +59,10 @@ export default async function Index(
const checkIfUserExists = await prisma.user.findFirst({
where: emailEnabled
? {
email: body.email?.toLowerCase(),
emailVerified: { not: null },
email: body.email?.toLowerCase().trim(),
}
: {
username: (body.username as string).toLowerCase(),
username: (body.username as string).toLowerCase().trim(),
},
});
@@ -64,16 +76,16 @@ export default async function Index(
name: body.name,
username: emailEnabled
? undefined
: (body.username as string).toLowerCase(),
email: emailEnabled ? body.email?.toLowerCase() : undefined,
: (body.username as string).toLowerCase().trim(),
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
password: hashedPassword,
},
});
return res.status(201).json({ response: "User successfully created." });
} else if (checkIfUserExists) {
return res
.status(400)
.json({ response: "Username and/or Email already exists." });
return res.status(400).json({
response: `${emailEnabled ? "Email" : "Username"} already exists.`,
});
}
}
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/api/db";
import bcrypt from "bcrypt";
import removeFolder from "@/lib/api/storage/removeFolder";
import Stripe from "stripe";
import { DeleteUserBody } from "@/types/global";
import removeFile from "@/lib/api/storage/removeFile";
export default async function deleteUserById(
userId: number,
body: DeleteUserBody
) {
// First, we retrieve the user from the database
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
return {
response: "Invalid credentials.",
status: 404,
};
}
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
if (!process.env.KEYCLOAK_CLIENT_SECRET) {
const isPasswordValid = bcrypt.compareSync(
body.password,
user.password as string
);
if (!isPasswordValid) {
return {
response: "Invalid credentials.",
status: 401, // Unauthorized
};
}
}
// Delete the user and all related data within a transaction
await prisma
.$transaction(
async (prisma) => {
// Delete whitelisted users
await prisma.whitelistedUser.deleteMany({
where: { userId },
});
// Delete links
await prisma.link.deleteMany({
where: { collection: { ownerId: userId } },
});
// Delete tags
await prisma.tag.deleteMany({
where: { ownerId: userId },
});
// Find collections that the user owns
const collections = await prisma.collection.findMany({
where: { ownerId: userId },
});
for (const collection of collections) {
// Delete related users and collections relations
await prisma.usersAndCollections.deleteMany({
where: { collectionId: collection.id },
});
// Delete archive folders
removeFolder({ filePath: `archives/${collection.id}` });
}
// Delete collections after cleaning up related data
await prisma.collection.deleteMany({
where: { ownerId: userId },
});
// Delete subscription
if (process.env.STRIPE_SECRET_KEY)
await prisma.subscription.delete({
where: { userId },
});
// Delete user's avatar
await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
// Finally, delete the user
await prisma.user.delete({
where: { id: userId },
});
},
{ timeout: 20000 }
)
.catch((err) => console.log(err));
if (process.env.STRIPE_SECRET_KEY) {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
try {
const listByEmail = await stripe.customers.list({
email: user.email?.toLowerCase(),
expand: ["data.subscriptions"],
});
if (listByEmail.data[0].subscriptions?.data[0].id) {
const deleted = await stripe.subscriptions.cancel(
listByEmail.data[0].subscriptions?.data[0].id,
{
cancellation_details: {
comment: body.cancellation_details?.comment,
feedback: body.cancellation_details?.feedback,
},
}
);
return {
response: deleted,
status: 200,
};
}
} catch (err) {
console.log(err);
}
}
return {
response: "User account and all related data deleted successfully.",
status: 200,
};
}
@@ -0,0 +1,36 @@
import { prisma } from "@/lib/api/db";
export default async function getUserById(userId: number) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
whitelistedUsers: {
select: {
username: true,
},
},
subscriptions: true,
},
});
if (!user)
return { response: "User not found or profile is private.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(
(usernames) => usernames.username
);
const { password, subscriptions, ...lessSensitiveInfo } = user;
const data = {
...lessSensitiveInfo,
whitelistedUsers: whitelistedUsernames,
subscription: {
active: subscriptions?.active,
},
};
return { response: data, status: 200 };
}
@@ -9,29 +9,38 @@ 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,
sessionUser: {
id: number;
username: string;
email: string;
isSubscriber: boolean;
}
export default async function updateUserById(
userId: number,
data: AccountSettings
) {
if (emailEnabled && !user.email)
if (emailEnabled && !data.email)
return {
response: "Email invalid.",
status: 400,
};
else if (!user.username)
else if (!data.username)
return {
response: "Username invalid.",
status: 400,
};
if (data.newPassword && data.newPassword?.length < 8)
return {
response: "Password must be at least 8 characters.",
status: 400,
};
// Check email (if enabled)
const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
if (emailEnabled && !checkEmail.test(data.email?.toLowerCase() || ""))
return {
response: "Please enter a valid email.",
status: 400,
};
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!checkUsername.test(user.username.toLowerCase()))
if (!checkUsername.test(data.username.toLowerCase()))
return {
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
@@ -40,43 +49,55 @@ export default async function updateUser(
const userIsTaken = await prisma.user.findFirst({
where: {
id: { not: sessionUser.id },
id: { not: userId },
OR: emailEnabled
? [
{
username: user.username.toLowerCase(),
username: data.username.toLowerCase(),
},
{
email: user.email?.toLowerCase(),
email: data.email?.toLowerCase(),
},
]
: [
{
username: user.username.toLowerCase(),
username: data.username.toLowerCase(),
},
],
},
});
if (userIsTaken)
if (userIsTaken) {
if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim())
return {
response: "Email is taken.",
status: 400,
};
else if (
data.username?.toLowerCase().trim() === userIsTaken.username?.trim()
)
return {
response: "Username is taken.",
status: 400,
};
return {
response: "Username/Email is taken.",
status: 400,
};
}
// Avatar Settings
const profilePic = user.profilePic;
if (profilePic.startsWith("data:image/jpeg;base64")) {
if (user.profilePic.length < 1572864) {
if (data.image?.startsWith("data:image/jpeg;base64")) {
if (data.image.length < 1572864) {
try {
const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, "");
const base64Data = data.image.replace(/^data:image\/jpeg;base64,/, "");
createFolder({ filePath: `uploads/avatar` });
await createFile({
filePath: `uploads/avatar/${sessionUser.id}.jpg`,
filePath: `uploads/avatar/${userId}.jpg`,
data: base64Data,
isBase64: true,
});
@@ -90,45 +111,54 @@ export default async function updateUser(
status: 400,
};
}
} else if (profilePic == "") {
removeFile({ filePath: `uploads/avatar/${sessionUser.id}.jpg` });
} else if (data.image == "") {
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
}
const previousEmail = (
await prisma.user.findUnique({ where: { id: userId } })
)?.email;
// Other settings
const saltRounds = 10;
const newHashedPassword = bcrypt.hashSync(user.newPassword || "", saltRounds);
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
const updatedUser = await prisma.user.update({
where: {
id: sessionUser.id,
id: userId,
},
data: {
name: user.name,
username: user.username.toLowerCase(),
email: user.email?.toLowerCase(),
isPrivate: user.isPrivate,
archiveAsScreenshot: user.archiveAsScreenshot,
archiveAsPDF: user.archiveAsPDF,
archiveAsWaybackMachine: user.archiveAsWaybackMachine,
name: data.name,
username: data.username.toLowerCase().trim(),
email: data.email?.toLowerCase().trim(),
isPrivate: data.isPrivate,
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsPDF: data.archiveAsPDF,
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
displayLinkIcons: data.displayLinkIcons,
blurredFavicons: data.blurredFavicons,
password:
user.newPassword && user.newPassword !== ""
data.newPassword && data.newPassword !== ""
? newHashedPassword
: undefined,
},
include: {
whitelistedUsers: true,
subscriptions: true,
},
});
const { whitelistedUsers, password, ...userInfo } = updatedUser;
const { whitelistedUsers, password, subscriptions, ...userInfo } =
updatedUser;
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
const newWhitelistedUsernames: string[] = user.whitelistedUsers || [];
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
// Get the current whitelisted usernames
const currentWhitelistedUsernames: string[] = whitelistedUsers.map(
(user) => user.username
(data) => data.username
);
// Find the usernames to be deleted (present in current but not in new)
@@ -145,7 +175,7 @@ export default async function updateUser(
// Delete whitelistedUsers that are not present in the new list
await prisma.whitelistedUser.deleteMany({
where: {
userId: sessionUser.id,
userId: userId,
username: {
in: usernamesToDelete,
},
@@ -157,24 +187,25 @@ export default async function updateUser(
await prisma.whitelistedUser.create({
data: {
username,
userId: sessionUser.id,
userId: userId,
},
});
}
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (STRIPE_SECRET_KEY && emailEnabled && sessionUser.email !== user.email)
if (STRIPE_SECRET_KEY && emailEnabled && previousEmail !== data.email)
await updateCustomerEmail(
STRIPE_SECRET_KEY,
sessionUser.email,
user.email as string
previousEmail as string,
data.email as string
);
const response: Omit<AccountSettings, "password"> = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
profilePic: `/api/avatar/${userInfo.id}?${Date.now()}`,
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
subscription: { active: subscriptions?.active },
};
return { response, status: 200 };
+25 -21
View File
@@ -1,34 +1,38 @@
import { prisma } from "@/lib/api/db";
export default async function getPermission(
userId: number,
collectionId: number,
linkId?: number
) {
if (linkId) {
const link = await prisma.link.findUnique({
where: {
id: linkId,
},
include: {
collection: {
include: { members: true },
},
},
});
type Props = {
userId: number;
collectionId?: number;
linkId?: number;
};
return link;
} else {
export default async function getPermission({
userId,
collectionId,
linkId,
}: Props) {
if (linkId) {
const check = await prisma.collection.findFirst({
where: {
AND: {
id: collectionId,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
links: {
some: {
id: linkId,
},
},
},
include: { members: true },
});
return check;
} else if (collectionId) {
const check = await prisma.collection.findFirst({
where: {
id: collectionId,
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
},
include: { members: true },
});
return check;
}
}
+122
View File
@@ -0,0 +1,122 @@
const { S3 } = require("@aws-sdk/client-s3");
const { PrismaClient } = require("@prisma/client");
const { existsSync } = require("fs");
const util = require("util");
const prisma = new PrismaClient();
const STORAGE_FOLDER = process.env.STORAGE_FOLDER || "data";
const s3Client =
process.env.SPACES_ENDPOINT &&
process.env.SPACES_REGION &&
process.env.SPACES_KEY &&
process.env.SPACES_SECRET
? new S3({
forcePathStyle: false,
endpoint: process.env.SPACES_ENDPOINT,
region: process.env.SPACES_REGION,
credentials: {
accessKeyId: process.env.SPACES_KEY,
secretAccessKey: process.env.SPACES_SECRET,
},
})
: undefined;
async function checkFileExistence(path) {
if (s3Client) {
const bucketParams = {
Bucket: process.env.BUCKET_NAME,
Key: path,
};
try {
const headObjectAsync = util.promisify(
s3Client.headObject.bind(s3Client)
);
try {
await headObjectAsync(bucketParams);
return true;
} catch (err) {
return false;
}
} catch (err) {
console.log("Error:", err);
return false;
}
} else {
try {
if (existsSync(STORAGE_FOLDER + "/" + path)) {
return true;
} else return false;
} catch (err) {
console.log(err);
}
}
}
// Avatars
async function migrateToV2() {
const users = await prisma.user.findMany();
for (let user of users) {
const path = `uploads/avatar/${user.id}.jpg`;
const res = await checkFileExistence(path);
if (res) {
await prisma.user.update({
where: { id: user.id },
data: { image: path },
});
console.log(`${user.id}`);
} else {
console.log(`${user.id}`);
}
}
const links = await prisma.link.findMany();
// PDFs
for (let link of links) {
const path = `archives/${link.collectionId}/${link.id}.pdf`;
const res = await checkFileExistence(path);
if (res) {
await prisma.link.update({
where: { id: link.id },
data: { pdfPath: path },
});
console.log(`${link.id}`);
} else {
console.log(`${link.id}`);
}
}
// Screenshots
for (let link of links) {
const path = `archives/${link.collectionId}/${link.id}.png`;
const res = await checkFileExistence(path);
if (res) {
await prisma.link.update({
where: { id: link.id },
data: { screenshotPath: path },
});
console.log(`${link.id}`);
} else {
console.log(`${link.id}`);
}
}
await prisma.$disconnect();
}
migrateToV2().catch((e) => {
console.error(e);
process.exit(1);
});
+3 -3
View File
@@ -14,12 +14,12 @@ export default async function paymentCheckout(
expand: ["data.subscriptions"],
});
const isExistingCostomer = listByEmail?.data[0]?.id || undefined;
const isExistingCustomer = listByEmail?.data[0]?.id || undefined;
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
const session = await stripe.checkout.sessions.create({
customer: isExistingCostomer ? isExistingCostomer : undefined,
customer: isExistingCustomer ? isExistingCustomer : undefined,
line_items: [
{
price: priceId,
@@ -27,7 +27,7 @@ export default async function paymentCheckout(
},
],
mode: "subscription",
customer_email: isExistingCostomer ? undefined : email.toLowerCase(),
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL}/login`,
automatic_tax: {
+1 -1
View File
@@ -14,7 +14,7 @@ export default async function createFile({
}) {
if (s3Client) {
const bucketParams: PutObjectCommandInput = {
Bucket: process.env.BUCKET_NAME,
Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath,
Body: isBase64 ? Buffer.from(data as string, "base64") : data,
};
+1 -1
View File
@@ -5,7 +5,7 @@ import removeFile from "./removeFile";
export default async function moveFile(from: string, to: string) {
if (s3Client) {
const Bucket = process.env.BUCKET_NAME;
const Bucket = process.env.SPACES_BUCKET_NAME;
const copyParams = {
Bucket: Bucket,
+16 -13
View File
@@ -9,19 +9,18 @@ import s3Client from "./s3Client";
import util from "util";
type ReturnContentTypes =
| "text/html"
| "text/plain"
| "image/jpeg"
| "image/png"
| "application/pdf";
| "application/pdf"
| "application/json";
export default async function readFile(filePath: string) {
const isRequestingAvatar = filePath.startsWith("uploads/avatar");
let contentType: ReturnContentTypes;
if (s3Client) {
const bucketParams: GetObjectCommandInput = {
Bucket: process.env.BUCKET_NAME,
Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath,
};
@@ -41,12 +40,12 @@ export default async function readFile(filePath: string) {
try {
await headObjectAsync(bucketParams);
} catch (err) {
contentType = "text/html";
contentType = "text/plain";
returnObject = {
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate,
file: "File not found.",
contentType,
status: isRequestingAvatar ? 200 : 400,
status: 400,
};
}
@@ -60,6 +59,8 @@ export default async function readFile(filePath: string) {
contentType = "application/pdf";
} else if (filePath.endsWith(".png")) {
contentType = "image/png";
} else if (filePath.endsWith("_readability.json")) {
contentType = "application/json";
} else {
// if (filePath.endsWith(".jpg"))
contentType = "image/jpeg";
@@ -71,9 +72,9 @@ export default async function readFile(filePath: string) {
} catch (err) {
console.log("Error:", err);
contentType = "text/html";
contentType = "text/plain";
return {
file: "An internal occurred, please contact support.",
file: "An internal occurred, please contact the support team.",
contentType,
};
}
@@ -85,6 +86,8 @@ export default async function readFile(filePath: string) {
contentType = "application/pdf";
} else if (filePath.endsWith(".png")) {
contentType = "image/png";
} else if (filePath.endsWith("_readability.json")) {
contentType = "application/json";
} else {
// if (filePath.endsWith(".jpg"))
contentType = "image/jpeg";
@@ -92,9 +95,9 @@ export default async function readFile(filePath: string) {
if (!fs.existsSync(creationPath))
return {
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate,
contentType: "text/html",
status: isRequestingAvatar ? 200 : 400,
file: "File not found.",
contentType: "text/plain",
status: 400,
};
else {
const file = fs.readFileSync(creationPath);
+1 -1
View File
@@ -6,7 +6,7 @@ import { PutObjectCommandInput, DeleteObjectCommand } from "@aws-sdk/client-s3";
export default async function removeFile({ filePath }: { filePath: string }) {
if (s3Client) {
const bucketParams: PutObjectCommandInput = {
Bucket: process.env.BUCKET_NAME,
Bucket: process.env.SPACES_BUCKET_NAME,
Key: filePath,
};
+1 -1
View File
@@ -40,7 +40,7 @@ async function emptyS3Directory(bucket: string, dir: string) {
export default async function removeFolder({ filePath }: { filePath: string }) {
if (s3Client) {
try {
await emptyS3Directory(process.env.BUCKET_NAME as string, filePath);
await emptyS3Directory(process.env.SPACES_BUCKET_NAME as string, filePath);
} catch (err) {
console.log("Error", err);
}
+1 -1
View File
@@ -6,7 +6,7 @@ const s3Client: S3 | undefined =
process.env.SPACES_KEY &&
process.env.SPACES_SECRET
? new S3({
forcePathStyle: false,
forcePathStyle: !!process.env.SPACES_FORCE_PATH_STYLE,
endpoint: process.env.SPACES_ENDPOINT,
region: process.env.SPACES_REGION,
credentials: {

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