commit
f9f2a8ca64
345
.env.sample
345
.env.sample
|
@ -12,7 +12,12 @@ PAGINATION_TAKE_COUNT=
|
||||||
STORAGE_FOLDER=
|
STORAGE_FOLDER=
|
||||||
AUTOSCROLL_TIMEOUT=
|
AUTOSCROLL_TIMEOUT=
|
||||||
NEXT_PUBLIC_DISABLE_REGISTRATION=
|
NEXT_PUBLIC_DISABLE_REGISTRATION=
|
||||||
|
NEXT_PUBLIC_CREDENTIALS_ENABLED=
|
||||||
|
DISABLE_NEW_SSO_USERS=
|
||||||
RE_ARCHIVE_LIMIT=
|
RE_ARCHIVE_LIMIT=
|
||||||
|
NEXT_PUBLIC_MAX_FILE_SIZE=
|
||||||
|
MAX_LINKS_PER_USER=
|
||||||
|
ARCHIVE_TAKE_COUNT=
|
||||||
|
|
||||||
# AWS S3 Settings
|
# AWS S3 Settings
|
||||||
SPACES_KEY=
|
SPACES_KEY=
|
||||||
|
@ -27,8 +32,348 @@ NEXT_PUBLIC_EMAIL_PROVIDER=
|
||||||
EMAIL_FROM=
|
EMAIL_FROM=
|
||||||
EMAIL_SERVER=
|
EMAIL_SERVER=
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# SSO Providers
|
||||||
|
#
|
||||||
|
|
||||||
|
# 42 School
|
||||||
|
NEXT_PUBLIC_FORTYTWO_ENABLED=
|
||||||
|
FORTYTWO_CUSTOM_NAME=
|
||||||
|
FORTYTWO_CLIENT_ID=
|
||||||
|
FORTYTWO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Apple
|
||||||
|
NEXT_PUBLIC_APPLE_ENABLED=
|
||||||
|
APPLE_CUSTOM_NAME=
|
||||||
|
APPLE_ID=
|
||||||
|
APPLE_SECRET=
|
||||||
|
|
||||||
|
# Atlassian
|
||||||
|
NEXT_PUBLIC_ATLASSIAN_ENABLED=
|
||||||
|
ATLASSIAN_CUSTOM_NAME=
|
||||||
|
ATLASSIAN_CLIENT_ID=
|
||||||
|
ATLASSIAN_CLIENT_SECRET=
|
||||||
|
ATLASSIAN_SCOPE=
|
||||||
|
|
||||||
|
# Auth0
|
||||||
|
NEXT_PUBLIC_AUTH0_ENABLED=
|
||||||
|
AUTH0_CUSTOM_NAME=
|
||||||
|
AUTH0_ISSUER=
|
||||||
|
AUTH0_CLIENT_SECRET=
|
||||||
|
AUTH0_CLIENT_ID=
|
||||||
|
|
||||||
|
# Authentik
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||||
|
AUTHENTIK_CUSTOM_NAME=
|
||||||
|
AUTHENTIK_ISSUER=
|
||||||
|
AUTHENTIK_CLIENT_ID=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Battle.net
|
||||||
|
NEXT_PUBLIC_BATTLENET_ENABLED=
|
||||||
|
BATTLENET_CUSTOM_NAME=
|
||||||
|
BATTLENET_CLIENT_ID=
|
||||||
|
BATTLENET_CLIENT_SECRET=
|
||||||
|
BATLLENET_ISSUER=
|
||||||
|
|
||||||
|
# Box
|
||||||
|
NEXT_PUBLIC_BOX_ENABLED=
|
||||||
|
BOX_CUSTOM_NAME=
|
||||||
|
BOX_CLIENT_ID=
|
||||||
|
BOX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Bungie
|
||||||
|
NEXT_PUBLIC_BUNGIE_ENABLED=
|
||||||
|
BUNGIE_CUSTOM_NAME=
|
||||||
|
BUNGIE_CLIENT_ID=
|
||||||
|
BUNGIE_CLIENT_SECRET=
|
||||||
|
BUNGIE_API_KEY=
|
||||||
|
|
||||||
|
# Cognito
|
||||||
|
NEXT_PUBLIC_COGNITO_ENABLED=
|
||||||
|
COGNITO_CUSTOM_NAME=
|
||||||
|
COGNITO_CLIENT_ID=
|
||||||
|
COGNITO_CLIENT_SECRET=
|
||||||
|
COGNITO_ISSUER=
|
||||||
|
|
||||||
|
# Coinbase
|
||||||
|
NEXT_PUBLIC_COINBASE_ENABLED=
|
||||||
|
COINBASE_CUSTOM_NAME=
|
||||||
|
COINBASE_CLIENT_ID=
|
||||||
|
COINBASE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
NEXT_PUBLIC_DISCORD_ENABLED=
|
||||||
|
DISCORD_CUSTOM_NAME=
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Dropbox
|
||||||
|
NEXT_PUBLIC_DROPBOX_ENABLED=
|
||||||
|
DROPBOX_CUSTOM_NAME=
|
||||||
|
DROPBOX_CLIENT_ID=
|
||||||
|
DROPBOX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# DuendeIndentityServer6
|
||||||
|
NEXT_PUBLIC_DUENDE_IDS6_ENABLED=
|
||||||
|
DUENDE_IDS6_CUSTOM_NAME=
|
||||||
|
DUENDE_IDS6_CLIENT_ID=
|
||||||
|
DUENDE_IDS6_CLIENT_SECRET=
|
||||||
|
DUENDE_IDS6_ISSUER=
|
||||||
|
|
||||||
|
# EVE Online
|
||||||
|
NEXT_PUBLIC_EVEONLINE_ENABLED=
|
||||||
|
EVEONLINE_CUSTOM_NAME=
|
||||||
|
EVEONLINE_CLIENT_ID=
|
||||||
|
EVEONLINE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Facebook
|
||||||
|
NEXT_PUBLIC_FACEBOOK_ENABLED=
|
||||||
|
FACEBOOK_CUSTOM_NAME=
|
||||||
|
FACEBOOK_CLIENT_ID=
|
||||||
|
FACEBOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# FACEIT
|
||||||
|
NEXT_PUBLIC_FACEIT_ENABLED=
|
||||||
|
FACEIT_CUSTOM_NAME=
|
||||||
|
FACEIT_CLIENT_ID=
|
||||||
|
FACEIT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Foursquare
|
||||||
|
NEXT_PUBLIC_FOURSQUARE_ENABLED=
|
||||||
|
FOURSQUARE_CUSTOM_NAME=
|
||||||
|
FOURSQUARE_CLIENT_ID=
|
||||||
|
FOURSQUARE_CLIENT_SECRET=
|
||||||
|
FOURSQUARE_APIVERSION=
|
||||||
|
|
||||||
|
# Freshbooks
|
||||||
|
NEXT_PUBLIC_FRESHBOOKS_ENABLED=
|
||||||
|
FRESHBOOKS_CUSTOM_NAME=
|
||||||
|
FRESHBOOKS_CLIENT_ID=
|
||||||
|
FRESHBOOKS_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# FusionAuth
|
||||||
|
NEXT_PUBLIC_FUSIONAUTH_ENABLED=
|
||||||
|
FUSIONAUTH_CUSTOM_NAME=
|
||||||
|
FUSIONAUTH_CLIENT_ID=
|
||||||
|
FUSIONAUTH_CLIENT_SECRET=
|
||||||
|
FUSIONAUTH_ISSUER=
|
||||||
|
FUSIONAUTH_TENANT_ID=
|
||||||
|
|
||||||
|
# GitHub
|
||||||
|
NEXT_PUBLIC_GITHUB_ENABLED=
|
||||||
|
GITHUB_CUSTOM_NAME=
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# GitLab
|
||||||
|
NEXT_PUBLIC_GITLAB_ENABLED=
|
||||||
|
GITLAB_CUSTOM_NAME=
|
||||||
|
GITLAB_CLIENT_ID=
|
||||||
|
GITLAB_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Google
|
||||||
|
NEXT_PUBLIC_GOOGLE_ENABLED=
|
||||||
|
GOOGLE_CUSTOM_NAME=
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# HubSpot
|
||||||
|
NEXT_PUBLIC_HUBSPOT_ENABLED=
|
||||||
|
HUBSPOT_CUSTOM_NAME=
|
||||||
|
HUBSPOT_CLIENT_ID=
|
||||||
|
HUBSPOT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# IdentityServer4
|
||||||
|
NEXT_PUBLIC_IDS4_ENABLED=
|
||||||
|
IDS4_CUSTOM_NAME=
|
||||||
|
IDS4_CLIENT_ID=
|
||||||
|
IDS4_CLIENT_SECRET=
|
||||||
|
IDS4_ISSUER=
|
||||||
|
|
||||||
|
# Kakao
|
||||||
|
NEXT_PUBLIC_KAKAO_ENABLED=
|
||||||
|
KAKAO_CUSTOM_NAME=
|
||||||
|
KAKAO_CLIENT_ID=
|
||||||
|
KAKAO_CLIENT_SECRET=
|
||||||
|
|
||||||
# Keycloak
|
# Keycloak
|
||||||
NEXT_PUBLIC_KEYCLOAK_ENABLED=
|
NEXT_PUBLIC_KEYCLOAK_ENABLED=
|
||||||
|
KEYCLOAK_CUSTOM_NAME=
|
||||||
KEYCLOAK_ISSUER=
|
KEYCLOAK_ISSUER=
|
||||||
KEYCLOAK_CLIENT_ID=
|
KEYCLOAK_CLIENT_ID=
|
||||||
KEYCLOAK_CLIENT_SECRET=
|
KEYCLOAK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# LINE
|
||||||
|
NEXT_PUBLIC_LINE_ENABLED=
|
||||||
|
LINE_CUSTOM_NAME=
|
||||||
|
LINE_CLIENT_ID=
|
||||||
|
LINE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# LinkedIn
|
||||||
|
NEXT_PUBLIC_LINKEDIN_ENABLED=
|
||||||
|
LINKEDIN_CUSTOM_NAME=
|
||||||
|
LINKEDIN_CLIENT_ID=
|
||||||
|
LINKEDIN_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Mailchimp
|
||||||
|
NEXT_PUBLIC_MAILCHIMP_ENABLED=
|
||||||
|
MAILCHIMP_CUSTOM_NAME=
|
||||||
|
MAILCHIMP_CLIENT_ID=
|
||||||
|
MAILCHIMP_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Mail.ru
|
||||||
|
NEXT_PUBLIC_MAILRU_ENABLED=
|
||||||
|
MAILRU_CUSTOM_NAME=
|
||||||
|
MAILRU_CLIENT_ID=
|
||||||
|
MAILRU_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Naver
|
||||||
|
NEXT_PUBLIC_NAVER_ENABLED=
|
||||||
|
NAVER_CUSTOM_NAME=
|
||||||
|
NAVER_CLIENT_ID=
|
||||||
|
NAVER_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Netlify
|
||||||
|
NEXT_PUBLIC_NETLIFY_ENABLED=
|
||||||
|
NETLIFY_CUSTOM_NAME=
|
||||||
|
NETLIFY_CLIENT_ID=
|
||||||
|
NETLIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Okta
|
||||||
|
NEXT_PUBLIC_OKTA_ENABLED=
|
||||||
|
OKTA_CUSTOM_NAME=
|
||||||
|
OKTA_CLIENT_ID=
|
||||||
|
OKTA_CLIENT_SECRET=
|
||||||
|
OKTA_ISSUER=
|
||||||
|
|
||||||
|
# OneLogin
|
||||||
|
NEXT_PUBLIC_ONELOGIN_ENABLED=
|
||||||
|
ONELOGIN_CUSTOM_NAME=
|
||||||
|
ONELOGIN_CLIENT_ID=
|
||||||
|
ONELOGIN_CLIENT_SECRET=
|
||||||
|
ONELOGIN_ISSUER=
|
||||||
|
|
||||||
|
# Osso
|
||||||
|
NEXT_PUBLIC_OSSO_ENABLED=
|
||||||
|
OSSO_CUSTOM_NAME=
|
||||||
|
OSSO_CLIENT_ID=
|
||||||
|
OSSO_CLIENT_SECRET=
|
||||||
|
OSSO_ISSUER=
|
||||||
|
|
||||||
|
# osu!
|
||||||
|
NEXT_PUBLIC_OSU_ENABLED=
|
||||||
|
OSU_CUSTOM_NAME=
|
||||||
|
OSU_CLIENT_ID=
|
||||||
|
OSU_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Patreon
|
||||||
|
NEXT_PUBLIC_PATREON_ENABLED=
|
||||||
|
PATREON_CUSTOM_NAME=
|
||||||
|
PATREON_CLIENT_ID=
|
||||||
|
PATREON_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Pinterest
|
||||||
|
NEXT_PUBLIC_PINTEREST_ENABLED=
|
||||||
|
PINTEREST_CUSTOM_NAME=
|
||||||
|
PINTEREST_CLIENT_ID=
|
||||||
|
PINTEREST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Pipedrive
|
||||||
|
NEXT_PUBLIC_PIPEDRIVE_ENABLED=
|
||||||
|
PIPEDRIVE_CUSTOM_NAME=
|
||||||
|
PIPEDRIVE_CLIENT_ID=
|
||||||
|
PIPEDRIVE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Reddit
|
||||||
|
NEXT_PUBLIC_REDDIT_ENABLED=
|
||||||
|
REDDIT_CUSTOM_NAME=
|
||||||
|
REDDIT_CLIENT_ID=
|
||||||
|
REDDIT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Salesforce
|
||||||
|
NEXT_PUBLIC_SALESFORCE_ENABLED=
|
||||||
|
SALESFORCE_CUSTOM_NAME=
|
||||||
|
SALESFORCE_CLIENT_ID=
|
||||||
|
SALESFORCE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Slack
|
||||||
|
NEXT_PUBLIC_SLACK_ENABLED=
|
||||||
|
SLACK_CUSTOM_NAME=
|
||||||
|
SLACK_CLIENT_ID=
|
||||||
|
SLACK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Spotify
|
||||||
|
NEXT_PUBLIC_SPOTIFY_ENABLED=
|
||||||
|
SPOTIFY_CUSTOM_NAME=
|
||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Strava
|
||||||
|
NEXT_PUBLIC_STRAVA_ENABLED=
|
||||||
|
STRAVA_CUSTOM_NAME=
|
||||||
|
STRAVA_CLIENT_ID=
|
||||||
|
STRAVA_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Todoist
|
||||||
|
NEXT_PUBLIC_TODOIST_ENABLED=
|
||||||
|
TODOIST_CUSTOM_NAME=
|
||||||
|
TODOIST_CLIENT_ID=
|
||||||
|
TODOIST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Twitch
|
||||||
|
NEXT_PUBLIC_TWITCH_ENABLED=
|
||||||
|
TWITCH_CUSTOM_NAME=
|
||||||
|
TWITCH_CLIENT_ID=
|
||||||
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# United Effects
|
||||||
|
NEXT_PUBLIC_UNITED_EFFECTS_ENABLED=
|
||||||
|
UNITED_EFFECTS_CUSTOM_NAME=
|
||||||
|
UNITED_EFFECTS_CLIENT_ID=
|
||||||
|
UNITED_EFFECTS_CLIENT_SECRET=
|
||||||
|
UNITED_EFFECTS_ISSUER=
|
||||||
|
|
||||||
|
# VK
|
||||||
|
NEXT_PUBLIC_VK_ENABLED=
|
||||||
|
VK_CUSTOM_NAME=
|
||||||
|
VK_CLIENT_ID=
|
||||||
|
VK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Wikimedia
|
||||||
|
NEXT_PUBLIC_WIKIMEDIA_ENABLED=
|
||||||
|
WIKIMEDIA_CUSTOM_NAME=
|
||||||
|
WIKIMEDIA_CLIENT_ID=
|
||||||
|
WIKIMEDIA_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Wordpress.com
|
||||||
|
NEXT_PUBLIC_WORDPRESS_ENABLED=
|
||||||
|
WORDPRESS_CUSTOM_NAME=
|
||||||
|
WORDPRESS_CLIENT_ID=
|
||||||
|
WORDPRESS_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Yandex
|
||||||
|
NEXT_PUBLIC_YANDEX_ENABLED=
|
||||||
|
YANDEX_CUSTOM_NAME=
|
||||||
|
YANDEX_CLIENT_ID=
|
||||||
|
YANDEX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Zitadel
|
||||||
|
NEXT_PUBLIC_ZITADEL_ENABLED=
|
||||||
|
ZITADEL_CUSTOM_NAME=
|
||||||
|
ZITADEL_CLIENT_ID=
|
||||||
|
ZITADEL_CLIENT_SECRET=
|
||||||
|
ZITADEL_ISSUER=
|
||||||
|
|
||||||
|
# Zoho
|
||||||
|
NEXT_PUBLIC_ZOHO_ENABLED=
|
||||||
|
ZOHO_CUSTOM_NAME=
|
||||||
|
ZOHO_CLIENT_ID=
|
||||||
|
ZOHO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Zoom
|
||||||
|
NEXT_PUBLIC_ZOOM_ENABLED=
|
||||||
|
ZOOM_CUSTOM_NAME=
|
||||||
|
ZOOM_CLIENT_ID=
|
||||||
|
ZOOM_CLIENT_SECRET=
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals",
|
"extends": "next/core-web-vitals",
|
||||||
"rules": {
|
"rules": {
|
||||||
"react-hooks/exhaustive-deps": "off"
|
"react-hooks/exhaustive-deps": "off",
|
||||||
|
"@next/next/no-img-element": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
{}
|
47
README.md
47
README.md
|
@ -2,10 +2,11 @@
|
||||||
<img src="./assets/logo.png" width="100px" />
|
<img src="./assets/logo.png" width="100px" />
|
||||||
<h1>Linkwarden</h1>
|
<h1>Linkwarden</h1>
|
||||||
|
|
||||||
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat-square" alt="Discord"></a>
|
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?style=flat&logo=discord&logoColor=white&color=%2350c728
|
||||||
<img alt="GitHub commits since latest release (by SemVer including pre-releases)" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/v1.1.0/dev">
|
" alt="Discord"></a>
|
||||||
<img src="https://img.shields.io/github/languages/top/linkwarden/linkwarden?style=flat-square" alt="Top Language">
|
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a>
|
||||||
<img src="https://img.shields.io/github/stars/linkwarden/linkwarden?style=flat-square" alt="Github Stars">
|
|
||||||
|
<img alt="GitHub commits since latest release" src="https://img.shields.io/github/commits-since/linkwarden/linkwarden/latest/dev?style=for-the-badge&label=COMMITS%20SINCE%20LATEST%20RELEASE">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -62,10 +63,24 @@ We've forked the old version from the current repository into [this repo](https:
|
||||||
- 📱 Responsive design and supports most modern browsers.
|
- 📱 Responsive design and supports most modern browsers.
|
||||||
- 🌓 Dark/Light mode support.
|
- 🌓 Dark/Light mode support.
|
||||||
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
|
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
|
||||||
- ⬇️ Import your bookmarks from other browsers.
|
- ⬇️ Import and export your bookmarks.
|
||||||
- ⚡️ Powerful API.
|
- 🔐 SSO integration. (Enterprise and Self-hosted users only)
|
||||||
- 🔐 SSO and Keycloak integration. (Enterprise and Self-hosted users only)
|
- ✨ And so many more features!
|
||||||
- ✅ And many more features!
|
|
||||||
|
## Like what we're doing? Give us a Star ⭐
|
||||||
|
|
||||||
|
![Star Us](https://raw.githubusercontent.com/linkwarden/linkwarden/main/assets/star_repo.gif)
|
||||||
|
|
||||||
|
## We're building our Community 🌐
|
||||||
|
|
||||||
|
Join and follow us in the following platforms to stay up to date about the most recent features and for support:
|
||||||
|
|
||||||
|
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?style=flat-square&logo=discord&logoColor=white&label=DISCORD&color=%2350c728
|
||||||
|
" alt="Discord"></a>
|
||||||
|
|
||||||
|
<a href="https://fosstodon.org/@linkwarden"><img src="https://img.shields.io/mastodon/follow/110748840237143200?domain=https%3A%2F%2Ffosstodon.org&style=flat-square&logo=mastodon&logoColor=white&label=MASTODON&color=%2350c728" alt="Mastodon"></a>
|
||||||
|
|
||||||
|
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden?label=TWITTER&style=flat-square&logo=x&logoColor=white&color=%2350c728" alt="Twitter"></a>
|
||||||
|
|
||||||
## Suggestions
|
## Suggestions
|
||||||
|
|
||||||
|
@ -79,17 +94,9 @@ Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/p
|
||||||
|
|
||||||
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
|
For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
|
||||||
|
|
||||||
## Main Tech Stack
|
|
||||||
|
|
||||||
- NextJS
|
|
||||||
- TypeScript
|
|
||||||
- Tailwind
|
|
||||||
- Prisma
|
|
||||||
- Zustand
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
If you want to contribute, Thanks! Start by checking our [public roadmap](https://github.com/orgs/linkwarden/projects/1), there you'll see a [README for contributers](https://github.com/orgs/linkwarden/projects/1?pane=issue&itemId=34708277) for the rest of the info on how to contribute to this repo.
|
If you want to contribute, Thanks! Start by checking our [public roadmap](https://github.com/orgs/linkwarden/projects/1), there you'll see a [README for contributers](https://github.com/orgs/linkwarden/projects/1?pane=issue&itemId=34708277) for the rest of the info on how to contribute and the main tech stack.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
@ -106,3 +113,9 @@ Here are the other ways to support/cheer this project:
|
||||||
- Referring Linkwarden to a friend.
|
- Referring Linkwarden to a friend.
|
||||||
|
|
||||||
If you did any of the above, Thanksss! Otherwise thanks.
|
If you did any of the above, Thanksss! Otherwise thanks.
|
||||||
|
|
||||||
|
## Thanks to All the Contributors 💪
|
||||||
|
|
||||||
|
Huge thanks to these guys for spending their time helping Linkwarden grow. They rock! ⚡️
|
||||||
|
|
||||||
|
<img src="https://contributors-img.web.app/image?repo=linkwarden/linkwarden" alt="Contributors"/>
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 2.4 MiB |
|
@ -0,0 +1,29 @@
|
||||||
|
type Props = {
|
||||||
|
onClick?: Function;
|
||||||
|
label: string;
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
type?: "button" | "submit" | "reset" | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccentSubmitButton({
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
loading,
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type ? type : undefined}
|
||||||
|
className={`border primary-btn-gradient select-none duration-200 bg-black border-[oklch(var(--p))] hover:border-[#0070b5] rounded-lg text-center px-4 py-2 text-white active:scale-95 tracking-wider w-fit flex justify-center items-center gap-2 ${
|
||||||
|
className || ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (loading !== undefined && !loading && onClick) onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="font-bold">{label}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +1,3 @@
|
||||||
import { faClose } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React, { MouseEventHandler } from "react";
|
import React, { MouseEventHandler } from "react";
|
||||||
|
|
||||||
|
@ -9,25 +7,25 @@ type Props = {
|
||||||
|
|
||||||
export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
|
export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed w-full z-20 dark:bg-neutral-900 bg-white">
|
<div className="fixed w-full z-20 bg-base-200">
|
||||||
<div className="w-full h-10 rainbow flex items-center justify-center">
|
<div className="w-full h-10 rainbow flex items-center justify-center">
|
||||||
<div className="w-fit font-semibold">
|
<div className="w-fit font-semibold">
|
||||||
🎉️{" "}
|
🎉️ See what's new in{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://blog.linkwarden.app/releases/v2.0"
|
href="https://blog.linkwarden.app/releases/v2.4"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="underline hover:opacity-50 duration-100"
|
className="underline hover:opacity-50 duration-100"
|
||||||
>
|
>
|
||||||
Linkwarden v2.0
|
Linkwarden v2.4
|
||||||
</Link>{" "}
|
</Link>
|
||||||
is now out! 🥳️
|
! 🥳️
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="fixed top-3 right-3 hover:opacity-50 duration-100"
|
className="fixed right-3 hover:opacity-50 duration-100"
|
||||||
onClick={toggleAnnouncementBar}
|
onClick={toggleAnnouncementBar}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faClose} className="w-4 h-4" />
|
<i className="bi-x text-neutral text-2xl"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { faSquare, faSquareCheck } from "@fortawesome/free-regular-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { ChangeEventHandler } from "react";
|
import { ChangeEventHandler } from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -12,23 +10,17 @@ type Props = {
|
||||||
export default function Checkbox({ label, state, className, onClick }: Props) {
|
export default function Checkbox({ label, state, className, onClick }: Props) {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`cursor-pointer flex items-center gap-2 ${className || ""}`}
|
className={`label cursor-pointer flex gap-2 justify-start ${
|
||||||
|
className || ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={state}
|
checked={state}
|
||||||
onChange={onClick}
|
onChange={onClick}
|
||||||
className="peer sr-only"
|
className="checkbox checkbox-primary"
|
||||||
/>
|
/>
|
||||||
<FontAwesomeIcon
|
<span className="label-text">{label}</span>
|
||||||
icon={faSquareCheck}
|
|
||||||
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:block hidden"
|
|
||||||
/>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faSquare}
|
|
||||||
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:hidden block"
|
|
||||||
/>
|
|
||||||
<span className="rounded select-none">{label}</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,31 +1,23 @@
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faEllipsis, faGlobe, faLink } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
import Dropdown from "./Dropdown";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import ProfilePhoto from "./ProfilePhoto";
|
import ProfilePhoto from "./ProfilePhoto";
|
||||||
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
|
|
||||||
import useModalStore from "@/store/modals";
|
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import { useTheme } from "next-themes";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import EditCollectionModal from "./ModalContent/EditCollectionModal";
|
||||||
|
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
|
||||||
|
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
collection: CollectionIncludingMembersAndLinkCount;
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DropdownTrigger =
|
|
||||||
| {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
| false;
|
|
||||||
|
|
||||||
export default function CollectionCard({ collection, className }: Props) {
|
export default function CollectionCard({ collection, className }: Props) {
|
||||||
const { setModal } = useModalStore();
|
const { settings } = useLocalSettingsStore();
|
||||||
|
const { account } = useAccountStore();
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
|
const formattedDate = new Date(collection.createdAt as string).toLocaleString(
|
||||||
"en-US",
|
"en-US",
|
||||||
|
@ -36,144 +28,189 @@ export default function CollectionCard({ collection, className }: Props) {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
|
|
||||||
|
|
||||||
const permissions = usePermissions(collection.id as number);
|
const permissions = usePermissions(collection.id as number);
|
||||||
|
|
||||||
|
const [collectionOwner, setCollectionOwner] = useState({
|
||||||
|
id: null as unknown as number,
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
image: "",
|
||||||
|
archiveAsScreenshot: undefined as unknown as boolean,
|
||||||
|
archiveAsPDF: undefined as unknown as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOwner = async () => {
|
||||||
|
if (collection && collection.ownerId !== account.id) {
|
||||||
|
const owner = await getPublicUserData(collection.ownerId as number);
|
||||||
|
setCollectionOwner(owner);
|
||||||
|
} else if (collection && collection.ownerId === account.id) {
|
||||||
|
setCollectionOwner({
|
||||||
|
id: account.id as number,
|
||||||
|
name: account.name,
|
||||||
|
username: account.username as string,
|
||||||
|
image: account.image as string,
|
||||||
|
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
|
||||||
|
archiveAsPDF: account.archiveAsPDF as boolean,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOwner();
|
||||||
|
}, [collection]);
|
||||||
|
|
||||||
|
const [editCollectionModal, setEditCollectionModal] = useState(false);
|
||||||
|
const [editCollectionSharingModal, setEditCollectionSharingModal] =
|
||||||
|
useState(false);
|
||||||
|
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative">
|
||||||
|
<div className="dropdown dropdown-bottom dropdown-end absolute top-3 right-3 z-20">
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||||
|
>
|
||||||
|
<i className="bi-three-dots text-xl" title="More"></i>
|
||||||
|
</div>
|
||||||
|
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
|
||||||
|
{permissions === true ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setEditCollectionModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Collection Info
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : undefined}
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setEditCollectionSharingModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permissions === true ? "Share and Collaborate" : "View Team"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setDeleteCollectionModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permissions === true ? "Delete Collection" : "Leave Collection"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
|
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
|
||||||
|
onClick={() => setEditCollectionSharingModal(true)}
|
||||||
|
>
|
||||||
|
{collectionOwner.id ? (
|
||||||
|
<ProfilePhoto
|
||||||
|
src={collectionOwner.image || undefined}
|
||||||
|
name={collectionOwner.name}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
{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}
|
||||||
|
name={e.user.name}
|
||||||
|
className="-ml-3"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.slice(0, 3)}
|
||||||
|
{collection.members.length - 3 > 0 ? (
|
||||||
|
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
|
||||||
|
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
|
||||||
|
<span>+{collection.members.length - 3}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/collections/${collection.id}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
|
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
|
||||||
theme === "dark" ? "#262626" : "#f3f4f6"
|
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
|
||||||
} 50%, ${theme === "dark" ? "#262626" : "#f9fafb"} 100%)`,
|
} 50%, ${
|
||||||
|
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
|
||||||
|
} 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="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content"
|
||||||
className || ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div
|
<div className="card-body flex flex-col justify-between min-h-[12rem]">
|
||||||
onClick={(e) => setExpandDropdown({ x: e.clientX, y: e.clientY })}
|
<div className="flex justify-between">
|
||||||
id={"expand-dropdown" + collection.id}
|
<p className="card-title break-words line-clamp-2 w-full">
|
||||||
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"
|
{collection.name}
|
||||||
>
|
</p>
|
||||||
<FontAwesomeIcon
|
<div className="w-8 h-8 ml-10"></div>
|
||||||
icon={faEllipsis}
|
</div>
|
||||||
id={"expand-dropdown" + collection.id}
|
|
||||||
className="w-5 h-5 text-gray-500 dark:text-gray-300"
|
<div className="flex justify-end items-center">
|
||||||
/>
|
<div className="text-right">
|
||||||
</div>
|
<div className="font-bold text-sm flex justify-end gap-1 items-center">
|
||||||
<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 ? (
|
{collection.isPublic ? (
|
||||||
<FontAwesomeIcon
|
<i
|
||||||
icon={faGlobe}
|
className="bi-globe-americas drop-shadow text-neutral"
|
||||||
title="This collection is being shared publicly."
|
title="This collection is being shared publicly."
|
||||||
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
|
></i>
|
||||||
/>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<FontAwesomeIcon
|
<i
|
||||||
icon={faLink}
|
className="bi-link-45deg text-lg text-neutral"
|
||||||
className="w-5 h-5 text-gray-500 dark:text-gray-300"
|
title="This collection is being shared publicly."
|
||||||
/>
|
></i>
|
||||||
{collection._count && collection._count.links}
|
{collection._count && collection._count.links}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-1 text-gray-500 dark:text-gray-300">
|
<div className="flex items-center justify-end gap-1 text-neutral">
|
||||||
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
|
<p className="font-bold text-xs flex gap-1 items-center">
|
||||||
<p className="font-bold text-xs">{formattedDate}</p>
|
<i
|
||||||
|
className="bi-calendar3 text-neutral"
|
||||||
|
title="This collection is being shared publicly."
|
||||||
|
></i>
|
||||||
|
{formattedDate}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
</Link>
|
||||||
{expandDropdown ? (
|
{editCollectionModal ? (
|
||||||
<Dropdown
|
<EditCollectionModal
|
||||||
points={{ x: expandDropdown.x, y: expandDropdown.y }}
|
onClose={() => setEditCollectionModal(false)}
|
||||||
items={[
|
activeCollection={collection}
|
||||||
permissions === true
|
|
||||||
? {
|
|
||||||
name: "Edit Collection Info",
|
|
||||||
onClick: () => {
|
|
||||||
collection &&
|
|
||||||
setModal({
|
|
||||||
modal: "COLLECTION",
|
|
||||||
state: true,
|
|
||||||
method: "UPDATE",
|
|
||||||
isOwner: permissions === true,
|
|
||||||
active: collection,
|
|
||||||
});
|
|
||||||
setExpandDropdown(false);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
{
|
|
||||||
name: permissions === true ? "Share/Collaborate" : "View Team",
|
|
||||||
onClick: () => {
|
|
||||||
collection &&
|
|
||||||
setModal({
|
|
||||||
modal: "COLLECTION",
|
|
||||||
state: true,
|
|
||||||
method: "UPDATE",
|
|
||||||
isOwner: permissions === true,
|
|
||||||
active: collection,
|
|
||||||
defaultIndex: permissions === true ? 1 : 0,
|
|
||||||
});
|
|
||||||
setExpandDropdown(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
name:
|
|
||||||
permissions === true ? "Delete Collection" : "Leave Collection",
|
|
||||||
onClick: () => {
|
|
||||||
collection &&
|
|
||||||
setModal({
|
|
||||||
modal: "COLLECTION",
|
|
||||||
state: true,
|
|
||||||
method: "UPDATE",
|
|
||||||
isOwner: permissions === true,
|
|
||||||
active: collection,
|
|
||||||
defaultIndex: permissions === true ? 2 : 1,
|
|
||||||
});
|
|
||||||
setExpandDropdown(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onClickOutside={(e: Event) => {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
if (target.id !== "expand-dropdown" + collection.id)
|
|
||||||
setExpandDropdown(false);
|
|
||||||
}}
|
|
||||||
className="w-fit"
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : undefined}
|
||||||
</>
|
{editCollectionSharingModal ? (
|
||||||
|
<EditCollectionSharingModal
|
||||||
|
onClose={() => setEditCollectionSharingModal(false)}
|
||||||
|
activeCollection={collection}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
{deleteCollectionModal ? (
|
||||||
|
<DeleteCollectionModal
|
||||||
|
onClose={() => setDeleteCollectionModal(false)}
|
||||||
|
activeCollection={collection}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,28 +1,20 @@
|
||||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
export default function dashboardItem({
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
name,
|
||||||
|
value,
|
||||||
type Props = {
|
icon,
|
||||||
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number;
|
||||||
icon: IconProp;
|
icon: string;
|
||||||
};
|
}) {
|
||||||
|
|
||||||
export default function dashboardItem({ name, value, icon }: Props) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 items-end">
|
<div className="flex items-center">
|
||||||
<div className="p-4 bg-sky-500 bg-opacity-20 dark:bg-opacity-10 rounded-xl select-none">
|
<div className="w-[4.7rem] aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
|
||||||
<FontAwesomeIcon
|
<i className={`${icon} text-primary text-4xl drop-shadow`}></i>
|
||||||
icon={icon}
|
|
||||||
className="w-8 h-8 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-center">
|
<div className="ml-4 flex flex-col justify-center">
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm tracking-wider">
|
<p className="text-neutral text-xs tracking-wider">{name}</p>
|
||||||
{name}
|
<p className="font-thin text-6xl text-primary mt-0.5">{value}</p>
|
||||||
</p>
|
|
||||||
<p className="font-thin text-6xl text-sky-500 dark:text-sky-500 mt-2">
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -78,13 +78,13 @@ export default function Dropdown({
|
||||||
onClickOutside={onClickOutside}
|
onClickOutside={onClickOutside}
|
||||||
className={`${
|
className={`${
|
||||||
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`}
|
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
|
||||||
>
|
>
|
||||||
{items.map((e, i) => {
|
{items.map((e, i) => {
|
||||||
const inner = e && (
|
const inner = e && (
|
||||||
<div className="cursor-pointer rounded-md">
|
<div className="cursor-pointer rounded-md">
|
||||||
<div className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 dark:hover:bg-neutral-700 duration-100">
|
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
|
||||||
<p className="text-black dark:text-white select-none">{e.name}</p>
|
<p className="select-none">{e.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
import React, { SetStateAction } from "react";
|
import React from "react";
|
||||||
import ClickAwayHandler from "./ClickAwayHandler";
|
|
||||||
import Checkbox from "./Checkbox";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
setFilterDropdown: (value: SetStateAction<boolean>) => void;
|
|
||||||
setSearchFilter: Function;
|
setSearchFilter: Function;
|
||||||
searchFilter: {
|
searchFilter: {
|
||||||
name: boolean;
|
name: boolean;
|
||||||
|
@ -15,64 +12,121 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FilterSearchDropdown({
|
export default function FilterSearchDropdown({
|
||||||
setFilterDropdown,
|
|
||||||
setSearchFilter,
|
setSearchFilter,
|
||||||
searchFilter,
|
searchFilter,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<ClickAwayHandler
|
<div className="dropdown dropdown-bottom dropdown-end">
|
||||||
onClickOutside={(e: Event) => {
|
<div
|
||||||
const target = e.target as HTMLInputElement;
|
tabIndex={0}
|
||||||
if (target.id !== "filter-dropdown") setFilterDropdown(false);
|
role="button"
|
||||||
}}
|
className="btn btn-sm btn-square btn-ghost"
|
||||||
className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-40"
|
>
|
||||||
>
|
<i
|
||||||
<p className="mb-2 text-black dark:text-white text-center font-semibold">
|
className="bi-funnel text-neutral text-2xl"
|
||||||
Filter by
|
></i>
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Checkbox
|
|
||||||
label="Name"
|
|
||||||
state={searchFilter.name}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Link"
|
|
||||||
state={searchFilter.url}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Description"
|
|
||||||
state={searchFilter.description}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchFilter({
|
|
||||||
...searchFilter,
|
|
||||||
description: !searchFilter.description,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Full Content"
|
|
||||||
state={searchFilter.textContent}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchFilter({
|
|
||||||
...searchFilter,
|
|
||||||
textContent: !searchFilter.textContent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label="Tags"
|
|
||||||
state={searchFilter.tags}
|
|
||||||
onClick={() =>
|
|
||||||
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ClickAwayHandler>
|
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1">
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="search-filter-checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
checked={searchFilter.name}
|
||||||
|
onChange={() => {
|
||||||
|
setSearchFilter({ ...searchFilter, name: !searchFilter.name });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Name</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="search-filter-checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
checked={searchFilter.url}
|
||||||
|
onChange={() => {
|
||||||
|
setSearchFilter({ ...searchFilter, url: !searchFilter.url });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Link</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="search-filter-checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
checked={searchFilter.description}
|
||||||
|
onChange={() => {
|
||||||
|
setSearchFilter({
|
||||||
|
...searchFilter,
|
||||||
|
description: !searchFilter.description,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Description</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="search-filter-checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
checked={searchFilter.textContent}
|
||||||
|
onChange={() => {
|
||||||
|
setSearchFilter({
|
||||||
|
...searchFilter,
|
||||||
|
textContent: !searchFilter.textContent,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Full Content</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="search-filter-checkbox"
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
checked={searchFilter.tags}
|
||||||
|
onChange={() => {
|
||||||
|
setSearchFilter({
|
||||||
|
...searchFilter,
|
||||||
|
tags: !searchFilter.tags,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Tags</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import useCollectionStore from "@/store/collections";
|
import useCollectionStore from "@/store/collections";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Select from "react-select";
|
|
||||||
import { styles } from "./styles";
|
import { styles } from "./styles";
|
||||||
import { Options } from "./types";
|
import { Options } from "./types";
|
||||||
|
import CreatableSelect from "react-select/creatable";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChange: any;
|
onChange: any;
|
||||||
|
@ -43,8 +43,8 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
|
||||||
}, [collections]);
|
}, [collections]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<CreatableSelect
|
||||||
isClearable
|
isClearable={false}
|
||||||
className="react-select-container"
|
className="react-select-container"
|
||||||
classNamePrefix="react-select"
|
classNamePrefix="react-select"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|
|
@ -27,7 +27,7 @@ export default function TagSelection({ onChange, defaultValue }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CreatableSelect
|
<CreatableSelect
|
||||||
isClearable
|
isClearable={false}
|
||||||
className="react-select-container"
|
className="react-select-container"
|
||||||
classNamePrefix="react-select"
|
classNamePrefix="react-select"
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|
|
@ -8,20 +8,27 @@ export const styles: StylesConfig = {
|
||||||
...styles,
|
...styles,
|
||||||
fontFamily: font,
|
fontFamily: font,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
backgroundColor: state.isSelected ? "#0ea5e9" : "inherit",
|
backgroundColor: state.isSelected ? "oklch(var(--p))" : "inherit",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: state.isSelected ? "#0ea5e9" : "#e2e8f0",
|
backgroundColor: state.isSelected
|
||||||
|
? "oklch(var(--p))"
|
||||||
|
: "oklch(var(--nc))",
|
||||||
},
|
},
|
||||||
transition: "all 50ms",
|
transition: "all 50ms",
|
||||||
}),
|
}),
|
||||||
control: (styles) => ({
|
control: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
fontFamily: font,
|
fontFamily: font,
|
||||||
border: "none",
|
borderRadius: "0.375rem",
|
||||||
|
border: state.isFocused
|
||||||
|
? "1px solid oklch(var(--p))"
|
||||||
|
: "1px solid oklch(var(--nc))",
|
||||||
|
boxShadow: "none",
|
||||||
|
minHeight: "2.6rem",
|
||||||
}),
|
}),
|
||||||
container: (styles) => ({
|
container: (styles, state) => ({
|
||||||
...styles,
|
...styles,
|
||||||
border: "1px solid #e0f2fe",
|
height: "full",
|
||||||
borderRadius: "0.375rem",
|
borderRadius: "0.375rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
// "@media screen and (min-width: 1024px)": {
|
// "@media screen and (min-width: 1024px)": {
|
||||||
|
@ -58,4 +65,5 @@ export const styles: StylesConfig = {
|
||||||
backgroundColor: "#38bdf8",
|
backgroundColor: "#38bdf8",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,301 +0,0 @@
|
||||||
import {
|
|
||||||
CollectionIncludingMembersAndLinkCount,
|
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
|
||||||
} from "@/types/global";
|
|
||||||
import {
|
|
||||||
faFolder,
|
|
||||||
faEllipsis,
|
|
||||||
faLink,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Dropdown from "./Dropdown";
|
|
||||||
import useLinkStore from "@/store/links";
|
|
||||||
import useCollectionStore from "@/store/collections";
|
|
||||||
import useAccountStore from "@/store/account";
|
|
||||||
import useModalStore from "@/store/modals";
|
|
||||||
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
|
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import isValidUrl from "@/lib/client/isValidUrl";
|
|
||||||
import Link from "next/link";
|
|
||||||
import unescapeString from "@/lib/client/unescapeString";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
count: number;
|
|
||||||
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<DropdownTrigger>(false);
|
|
||||||
|
|
||||||
const { collections } = useCollectionStore();
|
|
||||||
|
|
||||||
const { links } = useLinkStore();
|
|
||||||
|
|
||||||
const { account } = useAccountStore();
|
|
||||||
|
|
||||||
let shortendURL;
|
|
||||||
|
|
||||||
try {
|
|
||||||
shortendURL = new URL(link.url).host.toLowerCase();
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
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, links]);
|
|
||||||
|
|
||||||
const { removeLink, updateLink, getLink } = useLinkStore();
|
|
||||||
|
|
||||||
const pinLink = async () => {
|
|
||||||
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
|
|
||||||
|
|
||||||
const load = toast.loading("Applying...");
|
|
||||||
|
|
||||||
setExpandDropdown(false);
|
|
||||||
|
|
||||||
const response = await updateLink({
|
|
||||||
...link,
|
|
||||||
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
response.ok &&
|
|
||||||
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.id as number);
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
response.ok && toast.success(`Link Deleted.`);
|
|
||||||
setExpandDropdown(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = isValidUrl(link.url) ? new URL(link.url) : undefined;
|
|
||||||
|
|
||||||
const formattedDate = new Date(link.createdAt as string).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 || ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{(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
|
|
||||||
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";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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
|
|
||||||
? {
|
|
||||||
name:
|
|
||||||
link?.pinnedBy && link.pinnedBy[0]
|
|
||||||
? "Unpin"
|
|
||||||
: "Pin to Dashboard",
|
|
||||||
onClick: pinLink,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
permissions === true || permissions?.canUpdate
|
|
||||||
? {
|
|
||||||
name: "Edit",
|
|
||||||
onClick: () => {
|
|
||||||
setModal({
|
|
||||||
modal: "LINK",
|
|
||||||
state: true,
|
|
||||||
method: "UPDATE",
|
|
||||||
active: link,
|
|
||||||
});
|
|
||||||
setExpandDropdown(false);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
permissions === true
|
|
||||||
? {
|
|
||||||
name: "Refresh Link",
|
|
||||||
onClick: updateArchive,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
permissions === true || permissions?.canDelete
|
|
||||||
? {
|
|
||||||
name: "Delete",
|
|
||||||
onClick: deleteLink,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
]}
|
|
||||||
onClickOutside={(e: Event) => {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
if (target.id !== "expand-dropdown" + link.id)
|
|
||||||
setExpandDropdown(false);
|
|
||||||
}}
|
|
||||||
className="w-40"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,112 +0,0 @@
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,138 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import LinkCard from "@/components/LinkViews/LinkCard";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
|
||||||
|
export default function CardView({
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
|
||||||
|
{links.map((e, i) => {
|
||||||
|
return <LinkCard key={i} link={e} count={i} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import LinkGrid from "@/components/LinkViews/LinkGrid";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
|
||||||
|
export default function GridView({
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5">
|
||||||
|
{links.map((e, i) => {
|
||||||
|
return <LinkGrid link={e} count={i} key={i} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import LinkList from "@/components/LinkViews/LinkList";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
|
||||||
|
export default function ListView({
|
||||||
|
links,
|
||||||
|
}: {
|
||||||
|
links: LinkIncludingShortenedCollectionAndTags[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{links.map((e, i) => {
|
||||||
|
return <LinkList key={i} link={e} count={i} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,203 @@
|
||||||
|
import {
|
||||||
|
ArchivedFormat,
|
||||||
|
CollectionIncludingMembersAndLinkCount,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||||
|
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||||
|
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { previewAvailable } from "@/lib/shared/getArchiveValidity";
|
||||||
|
import Link from "next/link";
|
||||||
|
import LinkIcon from "./LinkComponents/LinkIcon";
|
||||||
|
import LinkGroupedIconURL from "./LinkComponents/LinkGroupedIconURL";
|
||||||
|
import useOnScreen from "@/hooks/useOnScreen";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
count: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LinkGrid({ link, count, className }: Props) {
|
||||||
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
const { links, getLink } = useLinkStore();
|
||||||
|
|
||||||
|
let shortendURL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, links]);
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const isVisible = useOnScreen(ref);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: any;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isVisible &&
|
||||||
|
!link.preview?.startsWith("archives") &&
|
||||||
|
link.preview !== "unavailable"
|
||||||
|
) {
|
||||||
|
interval = setInterval(async () => {
|
||||||
|
getLink(link.id as number);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isVisible, link.preview]);
|
||||||
|
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative"
|
||||||
|
>
|
||||||
|
<div className="relative rounded-t-2xl h-40 overflow-hidden">
|
||||||
|
{previewAvailable(link) ? (
|
||||||
|
<Image
|
||||||
|
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
|
||||||
|
width={1280}
|
||||||
|
height={720}
|
||||||
|
alt=""
|
||||||
|
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
|
||||||
|
style={{ filter: "blur(2px)" }}
|
||||||
|
draggable="false"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
target.style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : link.preview === "unavailable" ? (
|
||||||
|
<div className="bg-gray-50 duration-100 h-40 bg-opacity-80"></div>
|
||||||
|
) : (
|
||||||
|
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
// background:
|
||||||
|
// "radial-gradient(circle, rgba(255, 255, 255, 0.5), transparent)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"
|
||||||
|
>
|
||||||
|
<LinkIcon link={link} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
|
||||||
|
<div className="p-3 mt-1">
|
||||||
|
<p className="truncate w-full pr-8 text-primary">
|
||||||
|
{unescapeString(link.name || link.description) || link.url}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={link.url || ""}
|
||||||
|
target="_blank"
|
||||||
|
title={link.url || ""}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
<div className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-60 duration-100">
|
||||||
|
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
|
||||||
|
<p className="text-sm truncate">{shortendURL}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
<div className="flex justify-between text-xs text-neutral px-3 pb-1">
|
||||||
|
<div className="cursor-pointer w-fit">
|
||||||
|
{collection ? (
|
||||||
|
<LinkCollection link={link} collection={collection} />
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
<LinkDate link={link} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showInfo ? (
|
||||||
|
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-2xl fade-in overflow-y-auto">
|
||||||
|
<div
|
||||||
|
onClick={() => setShowInfo(!showInfo)}
|
||||||
|
className=" float-right btn btn-sm outline-none btn-circle btn-ghost z-10"
|
||||||
|
>
|
||||||
|
<i className="bi-x text-neutral text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral text-lg font-semibold">Description</p>
|
||||||
|
|
||||||
|
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
<p>
|
||||||
|
{link.description ? (
|
||||||
|
unescapeString(link.description)
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral text-sm">
|
||||||
|
No description provided.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{link.tags[0] ? (
|
||||||
|
<>
|
||||||
|
<p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
|
||||||
|
|
||||||
|
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
|
||||||
|
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||||
|
<div className="flex gap-1 items-center flex-wrap">
|
||||||
|
{link.tags.map((e, i) => (
|
||||||
|
<Link
|
||||||
|
href={"/tags/" + e.id}
|
||||||
|
key={i}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||||
|
>
|
||||||
|
#{e.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<LinkActions
|
||||||
|
link={link}
|
||||||
|
collection={collection}
|
||||||
|
position="top-[10.75rem] right-3"
|
||||||
|
toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||||
|
linkInfo={showInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
CollectionIncludingMembersAndLinkCount,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
|
||||||
|
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
|
||||||
|
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
position?: string;
|
||||||
|
toggleShowInfo?: () => void;
|
||||||
|
linkInfo?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LinkActions({
|
||||||
|
link,
|
||||||
|
toggleShowInfo,
|
||||||
|
position,
|
||||||
|
linkInfo,
|
||||||
|
}: Props) {
|
||||||
|
const permissions = usePermissions(link.collection.id as number);
|
||||||
|
|
||||||
|
const [editLinkModal, setEditLinkModal] = useState(false);
|
||||||
|
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
|
||||||
|
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
|
||||||
|
|
||||||
|
const { account } = useAccountStore();
|
||||||
|
|
||||||
|
const { removeLink, updateLink } = useLinkStore();
|
||||||
|
|
||||||
|
const pinLink = async () => {
|
||||||
|
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
|
||||||
|
|
||||||
|
const load = toast.loading("Applying...");
|
||||||
|
|
||||||
|
const response = await updateLink({
|
||||||
|
...link,
|
||||||
|
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
response.ok &&
|
||||||
|
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteLink = async () => {
|
||||||
|
const load = toast.loading("Deleting...");
|
||||||
|
|
||||||
|
const response = await removeLink(link.id as number);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
response.ok && toast.success(`Link Deleted.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`dropdown dropdown-left absolute ${
|
||||||
|
position || "top-3 right-3"
|
||||||
|
} z-20`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
className="btn btn-ghost btn-sm btn-square text-neutral"
|
||||||
|
>
|
||||||
|
<i title="More" className="bi-three-dots text-xl" />
|
||||||
|
</div>
|
||||||
|
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
|
||||||
|
{permissions === true ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
pinLink();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link?.pinnedBy && link.pinnedBy[0]
|
||||||
|
? "Unpin"
|
||||||
|
: "Pin to Dashboard"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : undefined}
|
||||||
|
{linkInfo !== undefined && toggleShowInfo ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
toggleShowInfo();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!linkInfo ? "Show" : "Hide"} Link Details
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : undefined}
|
||||||
|
{permissions === true || permissions?.canUpdate ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setEditLinkModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit Link
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : undefined}
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setPreservedFormatsModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preserved Formats
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{permissions === true || permissions?.canDelete ? (
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={(e) => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : undefined}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editLinkModal ? (
|
||||||
|
<EditLinkModal
|
||||||
|
onClose={() => setEditLinkModal(false)}
|
||||||
|
activeLink={link}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
{deleteLinkModal ? (
|
||||||
|
<DeleteLinkModal
|
||||||
|
onClose={() => setDeleteLinkModal(false)}
|
||||||
|
activeLink={link}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
{preservedFormatsModal ? (
|
||||||
|
<PreservedFormatsModal
|
||||||
|
onClose={() => setPreservedFormatsModal(false)}
|
||||||
|
activeLink={link}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
{/* {expandedLink ? (
|
||||||
|
<ExpandedLink onClose={() => setExpandedLink(false)} link={link} />
|
||||||
|
) : undefined} */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import {
|
||||||
|
CollectionIncludingMembersAndLinkCount,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function LinkCollection({
|
||||||
|
link,
|
||||||
|
collection,
|
||||||
|
}: {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
collection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
router.push(`/collections/${link.collection.id}`);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
|
||||||
|
title={collection?.name}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="bi-folder-fill text-lg drop-shadow"
|
||||||
|
style={{ color: collection?.color }}
|
||||||
|
></i>
|
||||||
|
<p className="truncate capitalize">{collection?.name}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function LinkDate({ link }: {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
}) {
|
||||||
|
const formattedDate = new Date(link.createdAt as string).toLocaleString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 text-neutral">
|
||||||
|
<i className="bi-calendar3 text-lg"></i>
|
||||||
|
<p>{formattedDate}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import Image from "next/image";
|
||||||
|
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function LinkGroupedIconURL({
|
||||||
|
link,
|
||||||
|
}: {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
}) {
|
||||||
|
const url =
|
||||||
|
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
|
||||||
|
|
||||||
|
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
|
||||||
|
|
||||||
|
let shortendURL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={link.url || ""} target="_blank">
|
||||||
|
<div className="bg-white shadow-md rounded-md border-[2px] flex gap-1 item-center justify-center border-white select-none z-10 max-w-full">
|
||||||
|
{link.url && url && showFavicon ? (
|
||||||
|
<Image
|
||||||
|
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
alt=""
|
||||||
|
className="w-5 h-5 rounded"
|
||||||
|
draggable="false"
|
||||||
|
onError={() => {
|
||||||
|
setShowFavicon(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : showFavicon === false ? (
|
||||||
|
<i className="bi-link-45deg text-xl leading-none text-black"></i>
|
||||||
|
) : link.type === "pdf" ? (
|
||||||
|
<i className={`bi-file-earmark-pdf`}></i>
|
||||||
|
) : link.type === "image" ? (
|
||||||
|
<i className={`bi-file-earmark-image`}></i>
|
||||||
|
) : undefined}
|
||||||
|
<p className="truncate bg-white text-black mr-1">
|
||||||
|
<p className="text-sm">{shortendURL}</p>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import Image from "next/image";
|
||||||
|
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function LinkIcon({
|
||||||
|
link,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
width?: string;
|
||||||
|
}) {
|
||||||
|
const url =
|
||||||
|
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
|
||||||
|
|
||||||
|
const iconClasses: string =
|
||||||
|
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
|
||||||
|
" " +
|
||||||
|
(width || "w-12");
|
||||||
|
|
||||||
|
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{link.url && url && showFavicon ? (
|
||||||
|
<Image
|
||||||
|
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
alt=""
|
||||||
|
className={iconClasses}
|
||||||
|
draggable="false"
|
||||||
|
onError={() => {
|
||||||
|
setShowFavicon(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : showFavicon === false ? (
|
||||||
|
<div className={iconClasses}>
|
||||||
|
<i className="bi-link-45deg text-4xl text-black"></i>
|
||||||
|
</div>
|
||||||
|
) : link.type === "pdf" ? (
|
||||||
|
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
|
||||||
|
) : link.type === "image" ? (
|
||||||
|
<i className={`bi-file-earmark-image ${iconClasses}`}></i>
|
||||||
|
) : undefined}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
import {
|
||||||
|
CollectionIncludingMembersAndLinkCount,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||||
|
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||||
|
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||||
|
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
count: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LinkGrid({ link, count, className }: Props) {
|
||||||
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
const { links } = useLinkStore();
|
||||||
|
|
||||||
|
let shortendURL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, links]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative p-3">
|
||||||
|
<div
|
||||||
|
onClick={() => link.url && window.open(link.url || "", "_blank")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<LinkIcon link={link} width="w-12 mb-3" />
|
||||||
|
<p className="truncate w-full">
|
||||||
|
{unescapeString(link.name || link.description) || link.url}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-col text-xs text-neutral">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LinkCollection link={link} collection={collection} />
|
||||||
|
·
|
||||||
|
{link.url ? (
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
window.open(link.url || "", "_blank");
|
||||||
|
}}
|
||||||
|
className="flex items-center hover:opacity-60 cursor-pointer duration-100"
|
||||||
|
>
|
||||||
|
<p className="truncate">{shortendURL}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="badge badge-primary badge-sm my-1">
|
||||||
|
{link.type}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<LinkDate link={link} />
|
||||||
|
</div>
|
||||||
|
<p className="truncate">{unescapeString(link.description)}</p>
|
||||||
|
{link.tags[0] ? (
|
||||||
|
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||||
|
<div className="flex gap-1 items-center flex-wrap">
|
||||||
|
{link.tags.map((e, i) => (
|
||||||
|
<Link
|
||||||
|
href={"/tags/" + e.id}
|
||||||
|
key={i}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||||
|
>
|
||||||
|
#{e.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LinkActions
|
||||||
|
toggleShowInfo={() => {}}
|
||||||
|
linkInfo={false}
|
||||||
|
link={link}
|
||||||
|
collection={collection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,152 @@
|
||||||
|
import {
|
||||||
|
CollectionIncludingMembersAndLinkCount,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
|
||||||
|
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
|
||||||
|
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
|
||||||
|
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
count: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LinkCardCompact({ link, count, className }: Props) {
|
||||||
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
const { links } = useLinkStore();
|
||||||
|
|
||||||
|
let shortendURL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, links]);
|
||||||
|
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`border-neutral-content relative ${
|
||||||
|
!showInfo ? "hover:bg-base-300" : ""
|
||||||
|
} duration-200 rounded-lg`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => link.url && window.open(link.url || "", "_blank")}
|
||||||
|
className="flex items-center cursor-pointer py-3 px-3"
|
||||||
|
>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<LinkIcon link={link} width="sm:w-12 w-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[calc(100%-56px)] ml-2">
|
||||||
|
<p className="line-clamp-1 mr-8 text-primary">
|
||||||
|
{unescapeString(link.name || link.description) || link.url}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{collection ? (
|
||||||
|
<>
|
||||||
|
<LinkCollection link={link} collection={collection} />
|
||||||
|
·
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
{link.url ? (
|
||||||
|
<div className="flex items-center gap-1 max-w-full w-fit text-neutral">
|
||||||
|
<i className="bi-link-45deg text-base" />
|
||||||
|
<p className="truncate w-full">{shortendURL}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="badge badge-primary badge-sm my-1">
|
||||||
|
{link.type}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:block">·</span>
|
||||||
|
<LinkDate link={link} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LinkActions
|
||||||
|
link={link}
|
||||||
|
collection={collection}
|
||||||
|
position="top-3 right-3"
|
||||||
|
// toggleShowInfo={() => setShowInfo(!showInfo)}
|
||||||
|
// linkInfo={showInfo}
|
||||||
|
/>
|
||||||
|
{showInfo ? (
|
||||||
|
<div>
|
||||||
|
<div className="pb-3 mt-1 px-3">
|
||||||
|
<p className="text-neutral text-lg font-semibold">Description</p>
|
||||||
|
|
||||||
|
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
<p>
|
||||||
|
{link.description ? (
|
||||||
|
unescapeString(link.description)
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral text-sm">
|
||||||
|
No description provided.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{link.tags[0] ? (
|
||||||
|
<>
|
||||||
|
<p className="text-neutral text-lg mt-3 font-semibold">
|
||||||
|
Tags
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
|
||||||
|
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
|
||||||
|
<div className="flex gap-1 items-center flex-wrap">
|
||||||
|
{link.tags.map((e, i) => (
|
||||||
|
<Link
|
||||||
|
href={"/tags/" + e.id}
|
||||||
|
key={i}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||||
|
>
|
||||||
|
#{e.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider my-0 last:hidden h-[1px]"></div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React, { MouseEventHandler, ReactNode, useEffect } from "react";
|
||||||
|
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
toggleModal: Function;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Modal({ toggleModal, className, children }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "auto";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-y-auto pt-2 sm:py-2 fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-40">
|
||||||
|
<ClickAwayHandler
|
||||||
|
onClickOutside={toggleModal}
|
||||||
|
className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
|
||||||
|
className || ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible">
|
||||||
|
<div
|
||||||
|
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
|
||||||
|
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
|
||||||
|
>
|
||||||
|
<i className="bi-x text-neutral text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</ClickAwayHandler>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,123 +0,0 @@
|
||||||
import { Dispatch, SetStateAction, useState } from "react";
|
|
||||||
import {
|
|
||||||
faFolder,
|
|
||||||
faPenToSquare,
|
|
||||||
faPlus,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import useCollectionStore from "@/store/collections";
|
|
||||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
|
||||||
import SubmitButton from "@/components/SubmitButton";
|
|
||||||
import { HexColorPicker } from "react-colorful";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import TextInput from "@/components/TextInput";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
toggleCollectionModal: Function;
|
|
||||||
setCollection: Dispatch<
|
|
||||||
SetStateAction<CollectionIncludingMembersAndLinkCount>
|
|
||||||
>;
|
|
||||||
collection: CollectionIncludingMembersAndLinkCount;
|
|
||||||
method: "CREATE" | "UPDATE" | "VIEW_TEAM";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CollectionInfo({
|
|
||||||
toggleCollectionModal,
|
|
||||||
setCollection,
|
|
||||||
collection,
|
|
||||||
method,
|
|
||||||
}: Props) {
|
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
|
||||||
const { updateCollection, addCollection } = useCollectionStore();
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!collection) return null;
|
|
||||||
|
|
||||||
setSubmitLoader(true);
|
|
||||||
|
|
||||||
const load = toast.loading(
|
|
||||||
method === "UPDATE" ? "Applying..." : "Creating..."
|
|
||||||
);
|
|
||||||
|
|
||||||
let response;
|
|
||||||
|
|
||||||
if (method === "CREATE") response = await addCollection(collection);
|
|
||||||
else response = await updateCollection(collection);
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
toast.success(
|
|
||||||
`Collection ${method === "UPDATE" ? "Saved!" : "Created!"}`
|
|
||||||
);
|
|
||||||
toggleCollectionModal();
|
|
||||||
} else toast.error(response.data as string);
|
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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-black dark:text-white mb-2">Name</p>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<TextInput
|
|
||||||
value={collection.name}
|
|
||||||
placeholder="e.g. Example Collection"
|
|
||||||
onChange={(e) =>
|
|
||||||
setCollection({ ...collection, name: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="color-picker flex justify-between">
|
|
||||||
<div className="flex flex-col justify-between items-center w-32">
|
|
||||||
<p className="w-full text-black dark:text-white mb-2">Color</p>
|
|
||||||
<div style={{ color: collection.color }}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faFolder}
|
|
||||||
className="w-12 h-12 drop-shadow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="py-1 px-2 rounded-md text-xs font-semibold cursor-pointer text-black dark:text-white hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
|
|
||||||
onClick={() =>
|
|
||||||
setCollection({ ...collection, color: "#0ea5e9" })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<HexColorPicker
|
|
||||||
color={collection.color}
|
|
||||||
onChange={(e) => setCollection({ ...collection, color: e })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full">
|
|
||||||
<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..."
|
|
||||||
value={collection.description}
|
|
||||||
onChange={(e) =>
|
|
||||||
setCollection({
|
|
||||||
...collection,
|
|
||||||
description: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SubmitButton
|
|
||||||
onClick={submit}
|
|
||||||
loading={submitLoader}
|
|
||||||
label={method === "CREATE" ? "Add" : "Save"}
|
|
||||||
icon={method === "CREATE" ? faPlus : faPenToSquare}
|
|
||||||
className="mx-auto mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,124 +0,0 @@
|
||||||
import React, { useState } from "react";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
faRightFromBracket,
|
|
||||||
faTrashCan,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
|
||||||
import useCollectionStore from "@/store/collections";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import TextInput from "@/components/TextInput";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
toggleDeleteCollectionModal: Function;
|
|
||||||
collection: CollectionIncludingMembersAndLinkCount;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeleteCollection({
|
|
||||||
toggleDeleteCollectionModal,
|
|
||||||
collection,
|
|
||||||
}: Props) {
|
|
||||||
const [inputField, setInputField] = useState("");
|
|
||||||
|
|
||||||
const { removeCollection } = useCollectionStore();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (permissions === true) if (collection.name !== inputField) return null;
|
|
||||||
|
|
||||||
const load = toast.loading("Deleting...");
|
|
||||||
|
|
||||||
const response = await removeCollection(collection.id as number);
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
toast.success("Collection Deleted.");
|
|
||||||
toggleDeleteCollectionModal();
|
|
||||||
router.push("/collections");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const permissions = usePermissions(collection.id as number);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 justify-between sm:w-[35rem] w-80">
|
|
||||||
{permissions === true ? (
|
|
||||||
<>
|
|
||||||
<p className="text-red-500 font-bold text-center">Warning!</p>
|
|
||||||
|
|
||||||
<div className="max-h-[20rem] overflow-y-auto">
|
|
||||||
<div className="text-black dark:text-white">
|
|
||||||
<p>
|
|
||||||
Please note that deleting the collection will permanently remove
|
|
||||||
all its contents, including the following:
|
|
||||||
</p>
|
|
||||||
<div className="p-3">
|
|
||||||
<li className="list-inside">
|
|
||||||
Links: All links within the collection will be permanently
|
|
||||||
deleted.
|
|
||||||
</li>
|
|
||||||
<li className="list-inside">
|
|
||||||
Tags: All tags associated with the collection will be removed.
|
|
||||||
</li>
|
|
||||||
<li className="list-inside">
|
|
||||||
Screenshots/PDFs: Any screenshots or PDFs attached to links
|
|
||||||
within this collection will be permanently deleted.
|
|
||||||
</li>
|
|
||||||
<li className="list-inside">
|
|
||||||
Members: Any members who have been granted access to the
|
|
||||||
collection will lose their permissions and no longer be able
|
|
||||||
to view or interact with the content.
|
|
||||||
</li>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
Please double-check that you have backed up any essential data
|
|
||||||
and have informed the relevant members about this action.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<p className="text-black dark:text-white text-center">
|
|
||||||
To confirm, type "
|
|
||||||
<span className="font-bold">{collection.name}</span>
|
|
||||||
" in the box below:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
autoFocus={true}
|
|
||||||
value={inputField}
|
|
||||||
onChange={(e) => setInputField(e.target.value)}
|
|
||||||
placeholder={`Type "${collection.name}" Here.`}
|
|
||||||
className="w-3/4 mx-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-black dark:text-white">
|
|
||||||
Click the button below to leave the current collection.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`mx-auto mt-2 text-white flex items-center gap-2 py-2 px-5 rounded-md select-none font-bold duration-100 ${
|
|
||||||
permissions === true
|
|
||||||
? inputField === collection.name
|
|
||||||
? "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
|
|
||||||
: "cursor-not-allowed bg-red-300 dark:bg-red-900"
|
|
||||||
: "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
|
|
||||||
}`}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={permissions === true ? faTrashCan : faRightFromBracket}
|
|
||||||
className="h-5"
|
|
||||||
/>
|
|
||||||
{permissions === true ? "Delete" : "Leave"} Collection
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,441 +0,0 @@
|
||||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
faClose,
|
|
||||||
faCrown,
|
|
||||||
faPenToSquare,
|
|
||||||
faPlus,
|
|
||||||
faUserPlus,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import useCollectionStore from "@/store/collections";
|
|
||||||
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
|
|
||||||
import addMemberToCollection from "@/lib/client/addMemberToCollection";
|
|
||||||
import Checkbox from "../../Checkbox";
|
|
||||||
import SubmitButton from "@/components/SubmitButton";
|
|
||||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import getPublicUserData from "@/lib/client/getPublicUserData";
|
|
||||||
import TextInput from "@/components/TextInput";
|
|
||||||
import useAccountStore from "@/store/account";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
toggleCollectionModal: Function;
|
|
||||||
setCollection: Dispatch<
|
|
||||||
SetStateAction<CollectionIncludingMembersAndLinkCount>
|
|
||||||
>;
|
|
||||||
collection: CollectionIncludingMembersAndLinkCount;
|
|
||||||
method: "CREATE" | "UPDATE";
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TeamManagement({
|
|
||||||
toggleCollectionModal,
|
|
||||||
setCollection,
|
|
||||||
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 [memberUsername, setMemberUsername] = useState("");
|
|
||||||
|
|
||||||
const [collectionOwner, setCollectionOwner] = useState({
|
|
||||||
id: null,
|
|
||||||
name: "",
|
|
||||||
username: "",
|
|
||||||
image: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchOwner = async () => {
|
|
||||||
const owner = await getPublicUserData(collection.ownerId as number);
|
|
||||||
setCollectionOwner(owner);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchOwner();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { addCollection, updateCollection } = useCollectionStore();
|
|
||||||
|
|
||||||
const setMemberState = (newMember: Member) => {
|
|
||||||
if (!collection) return null;
|
|
||||||
|
|
||||||
setCollection({
|
|
||||||
...collection,
|
|
||||||
members: [...collection.members, newMember],
|
|
||||||
});
|
|
||||||
|
|
||||||
setMemberUsername("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
if (!collection) return null;
|
|
||||||
|
|
||||||
setSubmitLoader(true);
|
|
||||||
|
|
||||||
const load = toast.loading(
|
|
||||||
method === "UPDATE" ? "Applying..." : "Creating..."
|
|
||||||
);
|
|
||||||
|
|
||||||
let response;
|
|
||||||
|
|
||||||
if (method === "CREATE") response = await addCollection(collection);
|
|
||||||
else response = await updateCollection(collection);
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
toast.success("Collection Saved!");
|
|
||||||
toggleCollectionModal();
|
|
||||||
} else toast.error(response.data as string);
|
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
|
|
||||||
{permissions === true && (
|
|
||||||
<>
|
|
||||||
<p className="text-black dark:text-white">Make Public</p>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
label="Make this a public collection."
|
|
||||||
state={collection.isPublic}
|
|
||||||
onClick={() =>
|
|
||||||
setCollection({ ...collection, isPublic: !collection.isPublic })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-gray-500 dark:text-gray-300 text-sm">
|
|
||||||
This will let <b>Anyone</b> to view this collection.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{collection.isPublic ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-black dark:text-white mb-2">
|
|
||||||
Public Link (Click to copy)
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
try {
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(publicCollectionURL)
|
|
||||||
.then(() => toast.success("Copied!"));
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 dark:bg-neutral-950 border-sky-100 dark:border-neutral-700 border-solid border outline-none hover:border-sky-300 dark:hover:border-sky-600 duration-100 cursor-text"
|
|
||||||
>
|
|
||||||
{publicCollectionURL}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{permissions !== true && collection.isPublic && (
|
|
||||||
<hr className="mb-3 border border-sky-100 dark:border-neutral-700" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{permissions === true && (
|
|
||||||
<>
|
|
||||||
<p className="text-black dark:text-white">Member Management</p>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TextInput
|
|
||||||
value={memberUsername || ""}
|
|
||||||
placeholder="Username (without the '@')"
|
|
||||||
onChange={(e) => setMemberUsername(e.target.value)}
|
|
||||||
onKeyDown={(e) =>
|
|
||||||
e.key === "Enter" &&
|
|
||||||
addMemberToCollection(
|
|
||||||
account.username as string,
|
|
||||||
memberUsername || "",
|
|
||||||
collection,
|
|
||||||
setMemberState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onClick={() =>
|
|
||||||
addMemberToCollection(
|
|
||||||
account.username as string,
|
|
||||||
memberUsername || "",
|
|
||||||
collection,
|
|
||||||
setMemberState
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="flex items-center justify-center bg-sky-700 hover:bg-sky-600 duration-100 text-white w-10 h-10 p-2 rounded-md cursor-pointer"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faUserPlus} className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{collection?.members[0]?.user && (
|
|
||||||
<>
|
|
||||||
<p className="text-center text-gray-500 dark:text-gray-300 text-xs sm:text-sm">
|
|
||||||
(All Members have <b>Read</b> access to this collection.)
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-3 rounded-md">
|
|
||||||
{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"
|
|
||||||
>
|
|
||||||
{permissions === true && (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faClose}
|
|
||||||
className="absolute right-2 top-2 text-gray-500 dark:text-gray-300 h-4 hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
|
|
||||||
title="Remove Member"
|
|
||||||
onClick={() => {
|
|
||||||
const updatedMembers = collection.members.filter(
|
|
||||||
(member) => {
|
|
||||||
return member.user.username !== e.user.username;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setCollection({
|
|
||||||
...collection,
|
|
||||||
members: updatedMembers,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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 className="flex sm:block items-center justify-between gap-5 min-w-[10rem]">
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
className={`font-bold text-sm text-black dark:text-white ${
|
|
||||||
permissions === true ? "" : "mb-2"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Permissions
|
|
||||||
</p>
|
|
||||||
{permissions === true && (
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-300 mb-2">
|
|
||||||
(Click to toggle.)
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{permissions !== true &&
|
|
||||||
!e.canCreate &&
|
|
||||||
!e.canUpdate &&
|
|
||||||
!e.canDelete ? (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
|
||||||
Has no permissions.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className={
|
|
||||||
permissions === true
|
|
||||||
? "cursor-pointer mr-1"
|
|
||||||
: "mr-1"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="canCreate"
|
|
||||||
className="peer sr-only"
|
|
||||||
checked={e.canCreate}
|
|
||||||
onChange={() => {
|
|
||||||
if (permissions === true) {
|
|
||||||
const updatedMembers = collection.members.map(
|
|
||||||
(member) => {
|
|
||||||
if (
|
|
||||||
member.user.username === e.user.username
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...member,
|
|
||||||
canCreate: !e.canCreate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return member;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setCollection({
|
|
||||||
...collection,
|
|
||||||
members: updatedMembers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
|
|
||||||
permissions === true
|
|
||||||
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
|
|
||||||
: ""
|
|
||||||
} rounded p-1 select-none`}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label
|
|
||||||
className={
|
|
||||||
permissions === true
|
|
||||||
? "cursor-pointer mr-1"
|
|
||||||
: "mr-1"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="canUpdate"
|
|
||||||
className="peer sr-only"
|
|
||||||
checked={e.canUpdate}
|
|
||||||
onChange={() => {
|
|
||||||
if (permissions === true) {
|
|
||||||
const updatedMembers = collection.members.map(
|
|
||||||
(member) => {
|
|
||||||
if (
|
|
||||||
member.user.username === e.user.username
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...member,
|
|
||||||
canUpdate: !e.canUpdate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return member;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setCollection({
|
|
||||||
...collection,
|
|
||||||
members: updatedMembers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
|
|
||||||
permissions === true
|
|
||||||
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
|
|
||||||
: ""
|
|
||||||
} rounded p-1 select-none`}
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label
|
|
||||||
className={
|
|
||||||
permissions === true
|
|
||||||
? "cursor-pointer mr-1"
|
|
||||||
: "mr-1"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="canDelete"
|
|
||||||
className="peer sr-only"
|
|
||||||
checked={e.canDelete}
|
|
||||||
onChange={() => {
|
|
||||||
if (permissions === true) {
|
|
||||||
const updatedMembers = collection.members.map(
|
|
||||||
(member) => {
|
|
||||||
if (
|
|
||||||
member.user.username === e.user.username
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...member,
|
|
||||||
canDelete: !e.canDelete,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return member;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setCollection({
|
|
||||||
...collection,
|
|
||||||
members: updatedMembers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
|
|
||||||
permissions === true
|
|
||||||
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
|
|
||||||
: ""
|
|
||||||
} rounded p-1 select-none`}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="relative border px-2 rounded-md border-sky-100 dark:border-neutral-700 flex min-h-[7rem] sm:min-h-[5rem] gap-2 justify-between"
|
|
||||||
title={`'@${collectionOwner.username}' is the owner of this collection.`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ProfilePhoto
|
|
||||||
src={collectionOwner.image ? collectionOwner.image : undefined}
|
|
||||||
className="border-[3px]"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<p className="text-sm font-bold text-black dark:text-white">
|
|
||||||
{collectionOwner.name}
|
|
||||||
</p>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faCrown}
|
|
||||||
className="w-3 h-3 text-yellow-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-500 dark:text-gray-300">
|
|
||||||
@{collectionOwner.username}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col justify-center min-w-[10rem] text-black dark:text-white">
|
|
||||||
<p className={`font-bold text-sm`}>Permissions</p>
|
|
||||||
<p>Full Access (Owner)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{permissions === true && (
|
|
||||||
<SubmitButton
|
|
||||||
onClick={submit}
|
|
||||||
loading={submitLoader}
|
|
||||||
label={method === "CREATE" ? "Add" : "Save"}
|
|
||||||
icon={method === "CREATE" ? faPlus : faPenToSquare}
|
|
||||||
className="mx-auto mt-2"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,97 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
import { Tab } from "@headlessui/react";
|
|
||||||
import CollectionInfo from "./CollectionInfo";
|
|
||||||
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
|
||||||
import TeamManagement from "./TeamManagement";
|
|
||||||
import { useState } from "react";
|
|
||||||
import DeleteCollection from "./DeleteCollection";
|
|
||||||
import ViewTeam from "./ViewTeam";
|
|
||||||
|
|
||||||
type Props =
|
|
||||||
| {
|
|
||||||
toggleCollectionModal: Function;
|
|
||||||
activeCollection: CollectionIncludingMembersAndLinkCount;
|
|
||||||
method: "UPDATE";
|
|
||||||
isOwner: boolean;
|
|
||||||
className?: string;
|
|
||||||
defaultIndex?: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
toggleCollectionModal: Function;
|
|
||||||
activeCollection?: CollectionIncludingMembersAndLinkCount;
|
|
||||||
method: "CREATE";
|
|
||||||
isOwner: boolean;
|
|
||||||
className?: string;
|
|
||||||
defaultIndex?: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
toggleCollectionModal: Function;
|
|
||||||
activeCollection: CollectionIncludingMembersAndLinkCount;
|
|
||||||
method: "VIEW_TEAM";
|
|
||||||
isOwner: boolean;
|
|
||||||
className?: string;
|
|
||||||
defaultIndex?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CollectionModal({
|
|
||||||
className,
|
|
||||||
defaultIndex,
|
|
||||||
toggleCollectionModal,
|
|
||||||
isOwner,
|
|
||||||
activeCollection,
|
|
||||||
method,
|
|
||||||
}: Props) {
|
|
||||||
const [collection, setCollection] =
|
|
||||||
useState<CollectionIncludingMembersAndLinkCount>(
|
|
||||||
activeCollection || {
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
color: "#0ea5e9",
|
|
||||||
isPublic: false,
|
|
||||||
members: [],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<Tab.Group defaultIndex={defaultIndex}>
|
|
||||||
{method === "CREATE" && (
|
|
||||||
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
|
|
||||||
New Collection
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{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
|
|
||||||
? "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.Panels>
|
|
||||||
{(isOwner || method === "CREATE") && (
|
|
||||||
<Tab.Panel>
|
|
||||||
<CollectionInfo
|
|
||||||
toggleCollectionModal={toggleCollectionModal}
|
|
||||||
setCollection={setCollection}
|
|
||||||
collection={collection}
|
|
||||||
method={method}
|
|
||||||
/>
|
|
||||||
</Tab.Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{method === "UPDATE" && (
|
|
||||||
<>
|
|
||||||
<Tab.Panel>
|
|
||||||
<TeamManagement
|
|
||||||
toggleCollectionModal={toggleCollectionModal}
|
|
||||||
setCollection={setCollection}
|
|
||||||
collection={collection}
|
|
||||||
method={method}
|
|
||||||
/>
|
|
||||||
</Tab.Panel>
|
|
||||||
<Tab.Panel>
|
|
||||||
<DeleteCollection
|
|
||||||
toggleDeleteCollectionModal={toggleCollectionModal}
|
|
||||||
collection={collection}
|
|
||||||
/>
|
|
||||||
</Tab.Panel>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{method === "VIEW_TEAM" && (
|
|
||||||
<>
|
|
||||||
<Tab.Panel>
|
|
||||||
<ViewTeam collection={collection} />
|
|
||||||
</Tab.Panel>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Tab.Panels>
|
|
||||||
</Tab.Group>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,271 +0,0 @@
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
|
||||||
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 { faLink, faPlus } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import useCollectionStore from "@/store/collections";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import SubmitButton from "../../SubmitButton";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import Link from "next/link";
|
|
||||||
import TextInput from "@/components/TextInput";
|
|
||||||
import unescapeString from "@/lib/client/unescapeString";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
type Props =
|
|
||||||
| {
|
|
||||||
toggleLinkModal: Function;
|
|
||||||
method: "CREATE";
|
|
||||||
activeLink?: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
toggleLinkModal: Function;
|
|
||||||
method: "UPDATE";
|
|
||||||
activeLink: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AddOrEditLink({
|
|
||||||
toggleLinkModal,
|
|
||||||
method,
|
|
||||||
activeLink,
|
|
||||||
}: Props) {
|
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
|
||||||
|
|
||||||
const [optionsExpanded, setOptionsExpanded] = useState(
|
|
||||||
method === "UPDATE" ? true : false
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data } = useSession();
|
|
||||||
|
|
||||||
const [link, setLink] = useState<LinkIncludingShortenedCollectionAndTags>(
|
|
||||||
activeLink || {
|
|
||||||
name: "",
|
|
||||||
url: "",
|
|
||||||
description: "",
|
|
||||||
tags: [],
|
|
||||||
screenshotPath: "",
|
|
||||||
pdfPath: "",
|
|
||||||
readabilityPath: "",
|
|
||||||
textContent: "",
|
|
||||||
collection: {
|
|
||||||
name: "",
|
|
||||||
ownerId: data?.user.id as number,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { updateLink, addLink } = useLinkStore();
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { collections } = useCollectionStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (method === "CREATE") {
|
|
||||||
if (router.query.id) {
|
|
||||||
const currentCollection = collections.find(
|
|
||||||
(e) => e.id == Number(router.query.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
currentCollection &&
|
|
||||||
currentCollection.ownerId &&
|
|
||||||
router.asPath.startsWith("/collections/")
|
|
||||||
)
|
|
||||||
setLink({
|
|
||||||
...link,
|
|
||||||
collection: {
|
|
||||||
id: currentCollection.id,
|
|
||||||
name: currentCollection.name,
|
|
||||||
ownerId: currentCollection.ownerId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else
|
|
||||||
setLink({
|
|
||||||
...link,
|
|
||||||
collection: {
|
|
||||||
// id: ,
|
|
||||||
name: "Unorganized",
|
|
||||||
ownerId: data?.user.id as number,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setTags = (e: any) => {
|
|
||||||
const tagNames = e.map((e: any) => {
|
|
||||||
return { name: e.label };
|
|
||||||
});
|
|
||||||
|
|
||||||
setLink({ ...link, tags: tagNames });
|
|
||||||
};
|
|
||||||
|
|
||||||
const setCollection = (e: any) => {
|
|
||||||
if (e?.__isNew__) e.value = null;
|
|
||||||
|
|
||||||
setLink({
|
|
||||||
...link,
|
|
||||||
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setSubmitLoader(true);
|
|
||||||
|
|
||||||
let response;
|
|
||||||
const load = toast.loading(
|
|
||||||
method === "UPDATE" ? "Applying..." : "Creating..."
|
|
||||||
);
|
|
||||||
|
|
||||||
if (method === "UPDATE") response = await updateLink(link);
|
|
||||||
else response = await addLink(link);
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
toast.success(`Link ${method === "UPDATE" ? "Saved!" : "Created!"}`);
|
|
||||||
toggleLinkModal();
|
|
||||||
} else toast.error(response.data as string);
|
|
||||||
|
|
||||||
setSubmitLoader(false);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 sm:w-[35rem] w-80">
|
|
||||||
{method === "UPDATE" ? (
|
|
||||||
<div
|
|
||||||
className="text-gray-500 dark:text-gray-300 break-all w-full flex gap-2"
|
|
||||||
title={link.url}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faLink} className="w-6 h-6" />
|
|
||||||
<Link href={link.url} target="_blank" className="w-full">
|
|
||||||
{link.url}
|
|
||||||
</Link>
|
|
||||||
</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-black dark:text-white mb-2">Address (URL)</p>
|
|
||||||
<TextInput
|
|
||||||
value={link.url}
|
|
||||||
onChange={(e) => setLink({ ...link, url: e.target.value })}
|
|
||||||
placeholder="e.g. http://example.com/"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2 col-span-5">
|
|
||||||
<p className="text-black dark:text-white mb-2">Collection</p>
|
|
||||||
{link.collection.name ? (
|
|
||||||
<CollectionSelection
|
|
||||||
onChange={setCollection}
|
|
||||||
// defaultValue={{
|
|
||||||
// label: link.collection.name,
|
|
||||||
// value: link.collection.id,
|
|
||||||
// }}
|
|
||||||
defaultValue={
|
|
||||||
link.collection.id
|
|
||||||
? {
|
|
||||||
value: link.collection.id,
|
|
||||||
label: link.collection.name,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
value: null as unknown as number,
|
|
||||||
label: "Unorganized",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{optionsExpanded ? (
|
|
||||||
<div>
|
|
||||||
{/* <hr className="mb-3 border border-sky-100 dark:border-neutral-700" /> */}
|
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
|
||||||
<div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}>
|
|
||||||
<p className="text-black dark:text-white mb-2">Name</p>
|
|
||||||
<TextInput
|
|
||||||
value={link.name}
|
|
||||||
onChange={(e) => setLink({ ...link, name: e.target.value })}
|
|
||||||
placeholder="e.g. Example Link"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{method === "UPDATE" ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-black dark:text-white mb-2">Collection</p>
|
|
||||||
{link.collection.name ? (
|
|
||||||
<CollectionSelection
|
|
||||||
onChange={setCollection}
|
|
||||||
defaultValue={
|
|
||||||
link.collection.name && link.collection.id
|
|
||||||
? {
|
|
||||||
value: link.collection.id,
|
|
||||||
label: link.collection.name,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
value: null as unknown as number,
|
|
||||||
label: "Unorganized",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-black dark:text-white mb-2">Tags</p>
|
|
||||||
<TagSelection
|
|
||||||
onChange={setTags}
|
|
||||||
defaultValue={link.tags.map((e) => {
|
|
||||||
return { label: e.name, value: e.id };
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<p className="text-black dark:text-white mb-2">Description</p>
|
|
||||||
<textarea
|
|
||||||
value={unescapeString(link.description) as string}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLink({ ...link, description: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
method === "CREATE"
|
|
||||||
? "Will be auto generated if nothing is provided."
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
className="resize-none w-full rounded-md p-2 border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100 dark:bg-neutral-950"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
<div className="flex justify-between items-stretch mt-2">
|
|
||||||
<div
|
|
||||||
onClick={() => setOptionsExpanded(!optionsExpanded)}
|
|
||||||
className={`${
|
|
||||||
method === "UPDATE" ? "hidden" : ""
|
|
||||||
} rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 flex items-center px-2 w-fit text-sm`}
|
|
||||||
>
|
|
||||||
<p>{optionsExpanded ? "Hide" : "More"} Options</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SubmitButton
|
|
||||||
onClick={submit}
|
|
||||||
label={method === "CREATE" ? "Add" : "Save"}
|
|
||||||
icon={method === "CREATE" ? faPlus : faPenToSquare}
|
|
||||||
loading={submitLoader}
|
|
||||||
className={`${method === "CREATE" ? "" : "mx-auto"}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,206 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|
||||||
import AddOrEditLink from "./AddOrEditLink";
|
|
||||||
import PreservedFormats from "./PreservedFormats";
|
|
||||||
|
|
||||||
type Props =
|
|
||||||
| {
|
|
||||||
toggleLinkModal: Function;
|
|
||||||
method: "CREATE";
|
|
||||||
activeLink?: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
toggleLinkModal: Function;
|
|
||||||
method: "UPDATE";
|
|
||||||
activeLink: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
toggleLinkModal: Function;
|
|
||||||
method: "FORMATS";
|
|
||||||
activeLink: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LinkModal({
|
|
||||||
className,
|
|
||||||
toggleLinkModal,
|
|
||||||
activeLink,
|
|
||||||
method,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
{method === "CREATE" ? (
|
|
||||||
<>
|
|
||||||
<p className="ml-10 mt-[0.1rem] text-xl mb-3 font-thin">
|
|
||||||
Create a New Link
|
|
||||||
</p>
|
|
||||||
<AddOrEditLink toggleLinkModal={toggleLinkModal} method="CREATE" />
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{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,34 +0,0 @@
|
||||||
import { MouseEventHandler, ReactNode } from "react";
|
|
||||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
toggleModal: Function;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Modal({ toggleModal, className, children }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="overflow-y-auto py-2 fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-30">
|
|
||||||
<ClickAwayHandler
|
|
||||||
onClickOutside={toggleModal}
|
|
||||||
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
|
|
||||||
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
|
|
||||||
className="absolute top-5 left-5 inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 z-20 p-2"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faChevronLeft}
|
|
||||||
className="w-4 h-4 text-gray-500 dark:text-gray-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</ClickAwayHandler>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
activeCollection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteCollectionModal({
|
||||||
|
onClose,
|
||||||
|
activeCollection,
|
||||||
|
}: Props) {
|
||||||
|
const [collection, setCollection] =
|
||||||
|
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCollection(activeCollection);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
const { removeCollection } = useCollectionStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const [inputField, setInputField] = useState("");
|
||||||
|
|
||||||
|
const permissions = usePermissions(collection.id as number);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (permissions === true) if (collection.name !== inputField) return null;
|
||||||
|
|
||||||
|
if (!submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
if (!collection) return null;
|
||||||
|
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading("Deleting...");
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
response = await removeCollection(collection.id as any);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(`Deleted.`);
|
||||||
|
onClose();
|
||||||
|
router.push("/collections");
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin text-red-500">
|
||||||
|
{permissions === true ? "Delete" : "Leave"} Collection
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{permissions === true ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>
|
||||||
|
To confirm, type "
|
||||||
|
<span className="font-bold">{collection.name}</span>
|
||||||
|
" in the box below:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={inputField}
|
||||||
|
onChange={(e) => setInputField(e.target.value)}
|
||||||
|
placeholder={`Type "${collection.name}" Here.`}
|
||||||
|
className="w-3/4 mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="alert" className="alert alert-warning">
|
||||||
|
<i className="bi-exclamation-triangle text-xl"></i>
|
||||||
|
<span>
|
||||||
|
<b>Warning:</b> Deleting this collection will permanently erase
|
||||||
|
all its contents, and it will become inaccessible to everyone,
|
||||||
|
including members with previous access.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>Click the button below to leave the current collection.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={permissions === true && inputField !== collection.name}
|
||||||
|
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 ${
|
||||||
|
permissions === true
|
||||||
|
? inputField === collection.name
|
||||||
|
? "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
|
||||||
|
: "cursor-not-allowed bg-red-300 dark:bg-red-900"
|
||||||
|
: "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
|
||||||
|
}`}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
<i className="bi-trash text-xl"></i>
|
||||||
|
{permissions === true ? "Delete" : "Leave"} Collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteLinkModal({ onClose, activeLink }: Props) {
|
||||||
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||||
|
|
||||||
|
const { removeLink } = useLinkStore();
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLink(activeLink);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteLink = async () => {
|
||||||
|
const load = toast.loading("Deleting...");
|
||||||
|
|
||||||
|
const response = await removeLink(link.id as number);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
response.ok && toast.success(`Link Deleted.`);
|
||||||
|
|
||||||
|
if (router.pathname.startsWith("/links/[id]")) {
|
||||||
|
router.push("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin text-red-500">Delete Link</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>Are you sure you want to delete this Link?</p>
|
||||||
|
|
||||||
|
<div role="alert" className="alert alert-warning">
|
||||||
|
<i className="bi-exclamation-triangle text-xl"/>
|
||||||
|
<span>
|
||||||
|
<b>Warning:</b> This action is irreversible!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
|
||||||
|
'Delete' to bypass this confirmation in the future.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
|
||||||
|
onClick={deleteLink}
|
||||||
|
>
|
||||||
|
<i className="bi-trash text-xl"/>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,118 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { HexColorPicker } from "react-colorful";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
activeCollection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditCollectionModal({
|
||||||
|
onClose,
|
||||||
|
activeCollection,
|
||||||
|
}: Props) {
|
||||||
|
const [collection, setCollection] =
|
||||||
|
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||||
|
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
const { updateCollection } = useCollectionStore();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
if (!collection) return null;
|
||||||
|
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading("Updating...");
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
response = await updateCollection(collection as any);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(`Updated!`);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">Edit Collection Info</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="mb-2">Name</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<TextInput
|
||||||
|
className="bg-base-200"
|
||||||
|
value={collection.name}
|
||||||
|
placeholder="e.g. Example Collection"
|
||||||
|
onChange={(e) =>
|
||||||
|
setCollection({ ...collection, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="w-full mb-2">Color</p>
|
||||||
|
<div className="color-picker flex justify-between">
|
||||||
|
<div className="flex flex-col gap-2 items-center w-32">
|
||||||
|
<i
|
||||||
|
className="bi-folder-fill text-5xl drop-shadow"
|
||||||
|
style={{ color: collection.color }}
|
||||||
|
></i>
|
||||||
|
<div
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setCollection({ ...collection, color: "#0ea5e9" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HexColorPicker
|
||||||
|
color={collection.color}
|
||||||
|
onChange={(e) => setCollection({ ...collection, color: e })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="mb-2">Description</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
|
||||||
|
placeholder="The purpose of this Collection..."
|
||||||
|
value={collection.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCollection({
|
||||||
|
...collection,
|
||||||
|
description: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,452 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
|
||||||
|
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
|
import ProfilePhoto from "../ProfilePhoto";
|
||||||
|
import addMemberToCollection from "@/lib/client/addMemberToCollection";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
activeCollection: CollectionIncludingMembersAndLinkCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditCollectionSharingModal({
|
||||||
|
onClose,
|
||||||
|
activeCollection,
|
||||||
|
}: Props) {
|
||||||
|
const [collection, setCollection] =
|
||||||
|
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
|
||||||
|
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
const { updateCollection } = useCollectionStore();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
if (!collection) return null;
|
||||||
|
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading("Updating...");
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
response = await updateCollection(collection as any);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(`Updated!`);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [memberUsername, setMemberUsername] = useState("");
|
||||||
|
|
||||||
|
const [collectionOwner, setCollectionOwner] = useState({
|
||||||
|
id: null as unknown as number,
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
image: "",
|
||||||
|
archiveAsScreenshot: undefined as unknown as boolean,
|
||||||
|
archiveAsPDF: undefined as unknown as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOwner = async () => {
|
||||||
|
const owner = await getPublicUserData(collection.ownerId as number);
|
||||||
|
setCollectionOwner(owner);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOwner();
|
||||||
|
|
||||||
|
setCollection(activeCollection);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setMemberState = (newMember: Member) => {
|
||||||
|
if (!collection) return null;
|
||||||
|
|
||||||
|
setCollection({
|
||||||
|
...collection,
|
||||||
|
members: [...collection.members, newMember],
|
||||||
|
});
|
||||||
|
|
||||||
|
setMemberUsername("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">
|
||||||
|
{permissions === true ? "Share and Collaborate" : "Team"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{permissions === true && (
|
||||||
|
<div>
|
||||||
|
<p>Make Public</p>
|
||||||
|
|
||||||
|
<label className="label cursor-pointer justify-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={collection.isPublic}
|
||||||
|
onChange={() =>
|
||||||
|
setCollection({
|
||||||
|
...collection,
|
||||||
|
isPublic: !collection.isPublic,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
/>
|
||||||
|
<span className="label-text">Make this a public collection</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="text-neutral text-sm">
|
||||||
|
This will let <b>Anyone</b> to view this collection and it's
|
||||||
|
users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collection.isPublic ? (
|
||||||
|
<div className={permissions === true ? "pl-5" : ""}>
|
||||||
|
<p className="mb-2">Sharable Link (Click to copy)</p>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(publicCollectionURL)
|
||||||
|
.then(() => toast.success("Copied!"));
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border outline-none hover:border-primary dark:hover:border-primary duration-100 cursor-text"
|
||||||
|
>
|
||||||
|
{publicCollectionURL}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{permissions === true && <div className="divider my-3"></div>}
|
||||||
|
|
||||||
|
{permissions === true && (
|
||||||
|
<>
|
||||||
|
<p>Members</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TextInput
|
||||||
|
value={memberUsername || ""}
|
||||||
|
className="bg-base-200"
|
||||||
|
placeholder="Username (without the '@')"
|
||||||
|
onChange={(e) => setMemberUsername(e.target.value)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === "Enter" &&
|
||||||
|
addMemberToCollection(
|
||||||
|
account.username as string,
|
||||||
|
memberUsername || "",
|
||||||
|
collection,
|
||||||
|
setMemberState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() =>
|
||||||
|
addMemberToCollection(
|
||||||
|
account.username as string,
|
||||||
|
memberUsername || "",
|
||||||
|
collection,
|
||||||
|
setMemberState
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white btn-square btn-sm h-10 w-10"
|
||||||
|
>
|
||||||
|
<i className="bi-person-add text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{collection?.members[0]?.user && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col divide-y divide-neutral-content border border-neutral-content rounded-xl bg-base-200">
|
||||||
|
<div
|
||||||
|
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between"
|
||||||
|
title={`@${collectionOwner.username} is the owner of this collection`}
|
||||||
|
>
|
||||||
|
<div className={"flex items-center justify-between w-full"}>
|
||||||
|
<div className={"flex items-center"}>
|
||||||
|
<div className={"shrink-0"}>
|
||||||
|
<ProfilePhoto
|
||||||
|
src={
|
||||||
|
collectionOwner.image
|
||||||
|
? collectionOwner.image
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
name={collectionOwner.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={"grow ml-2"}>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{collectionOwner.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral">
|
||||||
|
@{collectionOwner.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold">Owner</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider my-0 last:hidden h-[3px]"></div>
|
||||||
|
|
||||||
|
{collection.members
|
||||||
|
.sort((a, b) => (a.userId as number) - (b.userId as number))
|
||||||
|
.map((e, i) => {
|
||||||
|
const roleLabel =
|
||||||
|
e.canCreate && e.canUpdate && e.canDelete
|
||||||
|
? "Admin"
|
||||||
|
: e.canCreate && !e.canUpdate && !e.canDelete
|
||||||
|
? "Contributor"
|
||||||
|
: !e.canCreate && !e.canUpdate && !e.canDelete
|
||||||
|
? "Viewer"
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={"flex items-center justify-between w-full"}
|
||||||
|
>
|
||||||
|
<div className={"flex items-center"}>
|
||||||
|
<div className={"shrink-0"}>
|
||||||
|
<ProfilePhoto
|
||||||
|
src={e.user.image ? e.user.image : undefined}
|
||||||
|
name={e.user.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={"grow ml-2"}>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{e.user.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral">
|
||||||
|
@{e.user.username}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"flex items-center gap-2"}>
|
||||||
|
{permissions === true ? (
|
||||||
|
<div className="dropdown dropdown-bottom dropdown-end">
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
className="btn btn-sm btn-primary font-normal"
|
||||||
|
>
|
||||||
|
{roleLabel}
|
||||||
|
<i className="bi-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-64 mt-1">
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`role-radio-${e.userId}`}
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
checked={
|
||||||
|
!e.canCreate &&
|
||||||
|
!e.canUpdate &&
|
||||||
|
!e.canDelete
|
||||||
|
}
|
||||||
|
onChange={() => {
|
||||||
|
const updatedMember = {
|
||||||
|
...e,
|
||||||
|
canCreate: false,
|
||||||
|
canUpdate: false,
|
||||||
|
canDelete: false,
|
||||||
|
};
|
||||||
|
const updatedMembers =
|
||||||
|
collection.members.map((member) =>
|
||||||
|
member.userId === e.userId
|
||||||
|
? updatedMember
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
setCollection({
|
||||||
|
...collection,
|
||||||
|
members: updatedMembers,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
document?.activeElement as HTMLElement
|
||||||
|
)?.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Viewer</p>
|
||||||
|
<p>Read-only access</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`role-radio-${e.userId}`}
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
checked={
|
||||||
|
e.canCreate &&
|
||||||
|
!e.canUpdate &&
|
||||||
|
!e.canDelete
|
||||||
|
}
|
||||||
|
onChange={() => {
|
||||||
|
const updatedMember = {
|
||||||
|
...e,
|
||||||
|
canCreate: true,
|
||||||
|
canUpdate: false,
|
||||||
|
canDelete: false,
|
||||||
|
};
|
||||||
|
const updatedMembers =
|
||||||
|
collection.members.map((member) =>
|
||||||
|
member.userId === e.userId
|
||||||
|
? updatedMember
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
setCollection({
|
||||||
|
...collection,
|
||||||
|
members: updatedMembers,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
document?.activeElement as HTMLElement
|
||||||
|
)?.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Contributor</p>
|
||||||
|
<p>Can view and create Links</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`role-radio-${e.userId}`}
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
checked={
|
||||||
|
e.canCreate &&
|
||||||
|
e.canUpdate &&
|
||||||
|
e.canDelete
|
||||||
|
}
|
||||||
|
onChange={() => {
|
||||||
|
const updatedMember = {
|
||||||
|
...e,
|
||||||
|
canCreate: true,
|
||||||
|
canUpdate: true,
|
||||||
|
canDelete: true,
|
||||||
|
};
|
||||||
|
const updatedMembers =
|
||||||
|
collection.members.map((member) =>
|
||||||
|
member.userId === e.userId
|
||||||
|
? updatedMember
|
||||||
|
: member
|
||||||
|
);
|
||||||
|
setCollection({
|
||||||
|
...collection,
|
||||||
|
members: updatedMembers,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
document?.activeElement as HTMLElement
|
||||||
|
)?.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Admin</p>
|
||||||
|
<p>Full access to all Links</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral">
|
||||||
|
{roleLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissions === true && (
|
||||||
|
<i
|
||||||
|
className={
|
||||||
|
"bi-x text-xl btn btn-sm btn-square btn-ghost text-neutral hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
|
||||||
|
}
|
||||||
|
title="Remove Member"
|
||||||
|
onClick={() => {
|
||||||
|
const updatedMembers =
|
||||||
|
collection.members.filter((member) => {
|
||||||
|
return (
|
||||||
|
member.user.username !== e.user.username
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setCollection({
|
||||||
|
...collection,
|
||||||
|
members: updatedMembers,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divider my-0 last:hidden h-[3px]"></div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissions === true && (
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,165 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
||||||
|
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditLinkModal({ onClose, activeLink }: Props) {
|
||||||
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||||
|
|
||||||
|
let shortendURL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { updateLink } = useLinkStore();
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
const setCollection = (e: any) => {
|
||||||
|
if (e?.__isNew__) e.value = null;
|
||||||
|
|
||||||
|
setLink({
|
||||||
|
...link,
|
||||||
|
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTags = (e: any) => {
|
||||||
|
const tagNames = e.map((e: any) => {
|
||||||
|
return { name: e.label };
|
||||||
|
});
|
||||||
|
|
||||||
|
setLink({ ...link, tags: tagNames });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLink(activeLink);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
const load = toast.loading("Updating...");
|
||||||
|
|
||||||
|
response = await updateLink(link);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(`Updated!`);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">Edit Link</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
{link.url ? (
|
||||||
|
<Link
|
||||||
|
href={link.url}
|
||||||
|
className="truncate text-neutral flex gap-2 mb-5 w-fit max-w-full"
|
||||||
|
title={link.url}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<i className="bi-link-45deg text-xl"/>
|
||||||
|
<p>{shortendURL}</p>
|
||||||
|
</Link>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="mb-2">Name</p>
|
||||||
|
<TextInput
|
||||||
|
value={link.name}
|
||||||
|
onChange={(e) => setLink({ ...link, name: e.target.value })}
|
||||||
|
placeholder="e.g. Example Link"
|
||||||
|
className="bg-base-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{/* <hr className="mb-3 border border-neutral-content" /> */}
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Collection</p>
|
||||||
|
{link.collection.name ? (
|
||||||
|
<CollectionSelection
|
||||||
|
onChange={setCollection}
|
||||||
|
// defaultValue={{
|
||||||
|
// label: link.collection.name,
|
||||||
|
// value: link.collection.id,
|
||||||
|
// }}
|
||||||
|
defaultValue={
|
||||||
|
link.collection.id
|
||||||
|
? {
|
||||||
|
value: link.collection.id,
|
||||||
|
label: link.collection.name,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: null as unknown as number,
|
||||||
|
label: "Unorganized",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Tags</p>
|
||||||
|
<TagSelection
|
||||||
|
onChange={setTags}
|
||||||
|
defaultValue={link.tags.map((e) => {
|
||||||
|
return { label: e.name, value: e.id };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="mb-2">Description</p>
|
||||||
|
<textarea
|
||||||
|
value={unescapeString(link.description) as string}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLink({ ...link, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Will be auto generated if nothing is provided."
|
||||||
|
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end items-center mt-5">
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,118 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { HexColorPicker } from "react-colorful";
|
||||||
|
import { Collection } from "@prisma/client";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewCollectionModal({ onClose }: Props) {
|
||||||
|
const initial = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
color: "#0ea5e9",
|
||||||
|
};
|
||||||
|
|
||||||
|
const [collection, setCollection] = useState<Partial<Collection>>(initial);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCollection(initial);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
const { addCollection } = useCollectionStore();
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (submitLoader) return;
|
||||||
|
if (!collection) return null;
|
||||||
|
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
const load = toast.loading("Creating...");
|
||||||
|
|
||||||
|
let response = await addCollection(collection as any);
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success("Created!");
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">Create a New Collection</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="mb-2">Name</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<TextInput
|
||||||
|
className="bg-base-200"
|
||||||
|
value={collection.name}
|
||||||
|
placeholder="e.g. Example Collection"
|
||||||
|
onChange={(e) =>
|
||||||
|
setCollection({ ...collection, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="w-full mb-2">Color</p>
|
||||||
|
<div className="color-picker flex justify-between">
|
||||||
|
<div className="flex flex-col gap-2 items-center w-32">
|
||||||
|
<i
|
||||||
|
className={"bi-folder-fill text-5xl"}
|
||||||
|
style={{ color: collection.color }}
|
||||||
|
></i>
|
||||||
|
<div
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setCollection({ ...collection, color: "#0ea5e9" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HexColorPicker
|
||||||
|
color={collection.color}
|
||||||
|
onChange={(e) => setCollection({ ...collection, color: e })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="mb-2">Description</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
|
||||||
|
placeholder="The purpose of this Collection..."
|
||||||
|
value={collection.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCollection({
|
||||||
|
...collection,
|
||||||
|
description: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Create Collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,214 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
||||||
|
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NewLinkModal({ onClose }: Props) {
|
||||||
|
const { data } = useSession();
|
||||||
|
|
||||||
|
const initial = {
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
description: "",
|
||||||
|
type: "url",
|
||||||
|
tags: [],
|
||||||
|
preview: "",
|
||||||
|
image: "",
|
||||||
|
pdf: "",
|
||||||
|
readable: "",
|
||||||
|
textContent: "",
|
||||||
|
collection: {
|
||||||
|
name: "",
|
||||||
|
ownerId: data?.user.id as number,
|
||||||
|
},
|
||||||
|
} as LinkIncludingShortenedCollectionAndTags;
|
||||||
|
|
||||||
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(initial);
|
||||||
|
|
||||||
|
const { addLink } = useLinkStore();
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
const setCollection = (e: any) => {
|
||||||
|
if (e?.__isNew__) e.value = null;
|
||||||
|
|
||||||
|
setLink({
|
||||||
|
...link,
|
||||||
|
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTags = (e: any) => {
|
||||||
|
const tagNames = e.map((e: any) => {
|
||||||
|
return { name: e.label };
|
||||||
|
});
|
||||||
|
|
||||||
|
setLink({ ...link, tags: tagNames });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.query.id) {
|
||||||
|
const currentCollection = collections.find(
|
||||||
|
(e) => e.id == Number(router.query.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentCollection &&
|
||||||
|
currentCollection.ownerId &&
|
||||||
|
router.asPath.startsWith("/collections/")
|
||||||
|
)
|
||||||
|
setLink({
|
||||||
|
...initial,
|
||||||
|
collection: {
|
||||||
|
id: currentCollection.id,
|
||||||
|
name: currentCollection.name,
|
||||||
|
ownerId: currentCollection.ownerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
setLink({
|
||||||
|
...initial,
|
||||||
|
collection: {
|
||||||
|
name: "Unorganized",
|
||||||
|
ownerId: data?.user.id as number,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!submitLoader) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
const load = toast.loading("Creating...");
|
||||||
|
|
||||||
|
response = await addLink(link);
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(`Created!`);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">Create a New Link</p>
|
||||||
|
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
|
||||||
|
<div className="sm:col-span-3 col-span-5">
|
||||||
|
<p className="mb-2">Link</p>
|
||||||
|
<TextInput
|
||||||
|
value={link.url || ""}
|
||||||
|
onChange={(e) => setLink({ ...link, url: e.target.value })}
|
||||||
|
placeholder="e.g. http://example.com/"
|
||||||
|
className="bg-base-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2 col-span-5">
|
||||||
|
<p className="mb-2">Collection</p>
|
||||||
|
{link.collection.name ? (
|
||||||
|
<CollectionSelection
|
||||||
|
onChange={setCollection}
|
||||||
|
defaultValue={{
|
||||||
|
label: link.collection.name,
|
||||||
|
value: link.collection.id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={"mt-2"}>
|
||||||
|
{optionsExpanded ? (
|
||||||
|
<div className="mt-5">
|
||||||
|
{/* <hr className="mb-3 border border-neutral-content" /> */}
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Name</p>
|
||||||
|
<TextInput
|
||||||
|
value={link.name}
|
||||||
|
onChange={(e) => setLink({ ...link, name: e.target.value })}
|
||||||
|
placeholder="e.g. Example Link"
|
||||||
|
className="bg-base-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Tags</p>
|
||||||
|
<TagSelection
|
||||||
|
onChange={setTags}
|
||||||
|
defaultValue={link.tags.map((e) => {
|
||||||
|
return { label: e.name, value: e.id };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="mb-2">Description</p>
|
||||||
|
<textarea
|
||||||
|
value={unescapeString(link.description) as string}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLink({ ...link, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Will be auto generated if nothing is provided."
|
||||||
|
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mt-5">
|
||||||
|
<div
|
||||||
|
onClick={() => setOptionsExpanded(!optionsExpanded)}
|
||||||
|
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
|
||||||
|
>
|
||||||
|
<p className="font-normal">
|
||||||
|
{optionsExpanded ? "Hide" : "More"} Options
|
||||||
|
</p>
|
||||||
|
<i
|
||||||
|
className={`${
|
||||||
|
optionsExpanded ? "bi-chevron-up" : "bi-chevron-down"
|
||||||
|
}`}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Create Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,234 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import {
|
||||||
|
ArchivedFormat,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
pdfAvailable,
|
||||||
|
readabilityAvailable,
|
||||||
|
screenshotAvailable,
|
||||||
|
} from "@/lib/shared/getArchiveValidity";
|
||||||
|
import PreservedFormatRow from "@/components/PreserverdFormatRow";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import getPublicUserData from "@/lib/client/getPublicUserData";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
|
||||||
|
const session = useSession();
|
||||||
|
const { getLink } = useLinkStore();
|
||||||
|
|
||||||
|
const { account } = useAccountStore();
|
||||||
|
|
||||||
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||||
|
|
||||||
|
const [collectionOwner, setCollectionOwner] = useState({
|
||||||
|
id: null as unknown as number,
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
image: "",
|
||||||
|
archiveAsScreenshot: undefined as unknown as boolean,
|
||||||
|
archiveAsPDF: undefined as unknown as boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOwner = async () => {
|
||||||
|
if (link.collection.ownerId !== account.id) {
|
||||||
|
const owner = await getPublicUserData(
|
||||||
|
link.collection.ownerId as number
|
||||||
|
);
|
||||||
|
setCollectionOwner(owner);
|
||||||
|
} else if (link.collection.ownerId === account.id) {
|
||||||
|
setCollectionOwner({
|
||||||
|
id: account.id as number,
|
||||||
|
name: account.name,
|
||||||
|
username: account.username as string,
|
||||||
|
image: account.image as string,
|
||||||
|
archiveAsScreenshot: account.archiveAsScreenshot as boolean,
|
||||||
|
archiveAsPDF: account.archiveAsPDF as boolean,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOwner();
|
||||||
|
}, [link.collection.ownerId]);
|
||||||
|
|
||||||
|
const isReady = () => {
|
||||||
|
return (
|
||||||
|
collectionOwner.archiveAsScreenshot ===
|
||||||
|
(link && link.pdf && link.pdf !== "pending") &&
|
||||||
|
collectionOwner.archiveAsPDF ===
|
||||||
|
(link && link.pdf && link.pdf !== "pending") &&
|
||||||
|
link &&
|
||||||
|
link.readable &&
|
||||||
|
link.readable !== "pending"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const data = await getLink(link.id as number, isPublic);
|
||||||
|
setLink(
|
||||||
|
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
let interval: any;
|
||||||
|
|
||||||
|
if (!isReady()) {
|
||||||
|
interval = setInterval(async () => {
|
||||||
|
const data = await getLink(link.id as number, isPublic);
|
||||||
|
setLink(
|
||||||
|
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [link?.image, link?.pdf, link?.readable]);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const newLink = await getLink(link?.id as number);
|
||||||
|
setLink(
|
||||||
|
(newLink as any).response as LinkIncludingShortenedCollectionAndTags
|
||||||
|
);
|
||||||
|
toast.success(`Link is being archived...`);
|
||||||
|
} else toast.error(data.response);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<p className="text-xl font-thin">Preserved Formats</p>
|
||||||
|
|
||||||
|
<div className="divider mb-2 mt-1"></div>
|
||||||
|
|
||||||
|
{isReady() &&
|
||||||
|
(screenshotAvailable(link) ||
|
||||||
|
pdfAvailable(link) ||
|
||||||
|
readabilityAvailable(link)) ? (
|
||||||
|
<p className="mb-3">
|
||||||
|
The following formats are available for this link:
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`flex flex-col gap-3`}>
|
||||||
|
{isReady() ? (
|
||||||
|
<>
|
||||||
|
{screenshotAvailable(link) ? (
|
||||||
|
<PreservedFormatRow
|
||||||
|
name={"Screenshot"}
|
||||||
|
icon={"bi-file-earmark-image"}
|
||||||
|
format={
|
||||||
|
link?.image?.endsWith("png")
|
||||||
|
? ArchivedFormat.png
|
||||||
|
: ArchivedFormat.jpeg
|
||||||
|
}
|
||||||
|
activeLink={link}
|
||||||
|
downloadable={true}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{pdfAvailable(link) ? (
|
||||||
|
<PreservedFormatRow
|
||||||
|
name={"PDF"}
|
||||||
|
icon={"bi-file-earmark-pdf"}
|
||||||
|
format={ArchivedFormat.pdf}
|
||||||
|
activeLink={link}
|
||||||
|
downloadable={true}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
{readabilityAvailable(link) ? (
|
||||||
|
<PreservedFormatRow
|
||||||
|
name={"Readable"}
|
||||||
|
icon={"bi-file-earmark-text"}
|
||||||
|
format={ArchivedFormat.readability}
|
||||||
|
activeLink={link}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`w-full h-full flex flex-col justify-center p-10 skeleton bg-base-200`}
|
||||||
|
>
|
||||||
|
<i className="bi-stack drop-shadow text-primary text-8xl mx-auto mb-5"></i>
|
||||||
|
<p className="text-center text-2xl">
|
||||||
|
Link preservation is in the queue
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-lg">
|
||||||
|
Please check back later to see the result
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
|
||||||
|
isReady() ? "sm:mt " : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`https://web.archive.org/web/${link?.url?.replace(
|
||||||
|
/(^\w+:|^)\/\//,
|
||||||
|
""
|
||||||
|
)}`}
|
||||||
|
target="_blank"
|
||||||
|
className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm`}
|
||||||
|
>
|
||||||
|
<p className="whitespace-nowrap">
|
||||||
|
View latest snapshot on archive.org
|
||||||
|
</p>
|
||||||
|
<i className="bi-box-arrow-up-right" />
|
||||||
|
</Link>
|
||||||
|
{link?.collection.ownerId === session.data?.user.id ? (
|
||||||
|
<div
|
||||||
|
className={`btn w-1/2 btn-outline`}
|
||||||
|
onClick={() => updateArchive()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p>Refresh Preserved Formats</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
This deletes the current preservations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,246 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
|
||||||
|
import TagSelection from "@/components/InputSelect/TagSelection";
|
||||||
|
import TextInput from "@/components/TextInput";
|
||||||
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import useCollectionStore from "@/store/collections";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import {
|
||||||
|
ArchivedFormat,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Modal from "../Modal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: Function;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UploadFileModal({ onClose }: Props) {
|
||||||
|
const { data } = useSession();
|
||||||
|
|
||||||
|
const initial = {
|
||||||
|
name: "",
|
||||||
|
url: "",
|
||||||
|
description: "",
|
||||||
|
type: "url",
|
||||||
|
tags: [],
|
||||||
|
preview: "",
|
||||||
|
image: "",
|
||||||
|
pdf: "",
|
||||||
|
readable: "",
|
||||||
|
textContent: "",
|
||||||
|
collection: {
|
||||||
|
name: "",
|
||||||
|
ownerId: data?.user.id as number,
|
||||||
|
},
|
||||||
|
} as LinkIncludingShortenedCollectionAndTags;
|
||||||
|
|
||||||
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(initial);
|
||||||
|
|
||||||
|
const [file, setFile] = useState<File>();
|
||||||
|
|
||||||
|
const { addLink } = useLinkStore();
|
||||||
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
|
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
const setCollection = (e: any) => {
|
||||||
|
if (e?.__isNew__) e.value = null;
|
||||||
|
|
||||||
|
setLink({
|
||||||
|
...link,
|
||||||
|
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTags = (e: any) => {
|
||||||
|
const tagNames = e.map((e: any) => {
|
||||||
|
return { name: e.label };
|
||||||
|
});
|
||||||
|
|
||||||
|
setLink({ ...link, tags: tagNames });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOptionsExpanded(false);
|
||||||
|
if (router.query.id) {
|
||||||
|
const currentCollection = collections.find(
|
||||||
|
(e) => e.id == Number(router.query.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentCollection &&
|
||||||
|
currentCollection.ownerId &&
|
||||||
|
router.asPath.startsWith("/collections/")
|
||||||
|
)
|
||||||
|
setLink({
|
||||||
|
...initial,
|
||||||
|
collection: {
|
||||||
|
id: currentCollection.id,
|
||||||
|
name: currentCollection.name,
|
||||||
|
ownerId: currentCollection.ownerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else
|
||||||
|
setLink({
|
||||||
|
...initial,
|
||||||
|
collection: {
|
||||||
|
name: "Unorganized",
|
||||||
|
ownerId: data?.user.id as number,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!submitLoader && file) {
|
||||||
|
let fileType: ArchivedFormat | null = null;
|
||||||
|
let linkType: "url" | "image" | "pdf" | null = null;
|
||||||
|
|
||||||
|
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
|
||||||
|
fileType = ArchivedFormat.jpeg;
|
||||||
|
linkType = "image";
|
||||||
|
} else if (file.type === "image/png") {
|
||||||
|
fileType = ArchivedFormat.png;
|
||||||
|
linkType = "image";
|
||||||
|
} else if (file.type === "application/pdf") {
|
||||||
|
fileType = ArchivedFormat.pdf;
|
||||||
|
linkType = "pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType !== null && linkType !== null) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
|
||||||
|
const load = toast.loading("Creating...");
|
||||||
|
|
||||||
|
response = await addLink({
|
||||||
|
...link,
|
||||||
|
type: linkType,
|
||||||
|
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const formBody = new FormData();
|
||||||
|
file && formBody.append("file", file);
|
||||||
|
|
||||||
|
await fetch(
|
||||||
|
`/api/v1/archives/${
|
||||||
|
(response.data as LinkIncludingShortenedCollectionAndTags).id
|
||||||
|
}?format=${fileType}`,
|
||||||
|
{
|
||||||
|
body: formBody,
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
toast.success(`Created!`);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal toggleModal={onClose}>
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<p className="text-xl font-thin">Upload File</p>
|
||||||
|
</div>
|
||||||
|
<div className="divider mb-3 mt-1"></div>
|
||||||
|
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
|
||||||
|
<div className="sm:col-span-3 col-span-5">
|
||||||
|
<p className="mb-2">File</p>
|
||||||
|
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
|
className="cursor-pointer custom-file-input"
|
||||||
|
onChange={(e) => e.target.files && setFile(e.target.files[0])}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs font-semibold mt-2">
|
||||||
|
PDF, PNG, JPG (Up to {process.env.NEXT_PUBLIC_MAX_FILE_SIZE || 30}
|
||||||
|
MB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2 col-span-5">
|
||||||
|
<p className="mb-2">Collection</p>
|
||||||
|
{link.collection.name ? (
|
||||||
|
<CollectionSelection
|
||||||
|
onChange={setCollection}
|
||||||
|
defaultValue={{
|
||||||
|
label: link.collection.name,
|
||||||
|
value: link.collection.id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{optionsExpanded ? (
|
||||||
|
<div className="mt-5">
|
||||||
|
{/* <hr className="mb-3 border border-neutral-content" /> */}
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Name</p>
|
||||||
|
<TextInput
|
||||||
|
value={link.name}
|
||||||
|
onChange={(e) => setLink({ ...link, name: e.target.value })}
|
||||||
|
placeholder="e.g. Example Link"
|
||||||
|
className="bg-base-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2">Tags</p>
|
||||||
|
<TagSelection
|
||||||
|
onChange={setTags}
|
||||||
|
defaultValue={link.tags.map((e) => {
|
||||||
|
return { label: e.name, value: e.id };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="mb-2">Description</p>
|
||||||
|
<textarea
|
||||||
|
value={unescapeString(link.description) as string}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLink({ ...link, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Will be auto generated if nothing is provided."
|
||||||
|
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
<div className="flex justify-between items-center mt-5">
|
||||||
|
<div
|
||||||
|
onClick={() => setOptionsExpanded(!optionsExpanded)}
|
||||||
|
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
|
||||||
|
>
|
||||||
|
<p>{optionsExpanded ? "Hide" : "More"} Options</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-accent dark:border-violet-400 text-white"
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Create Link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,49 +0,0 @@
|
||||||
import useModalStore from "@/store/modals";
|
|
||||||
import Modal from "./Modal";
|
|
||||||
import LinkModal from "./Modal/Link";
|
|
||||||
import {
|
|
||||||
CollectionIncludingMembersAndLinkCount,
|
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
|
||||||
} from "@/types/global";
|
|
||||||
import CollectionModal from "./Modal/Collection";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
export default function ModalManagement() {
|
|
||||||
const { modal, setModal } = useModalStore();
|
|
||||||
|
|
||||||
const toggleModal = () => {
|
|
||||||
setModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
useEffect(() => {
|
|
||||||
toggleModal();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
if (modal && modal.modal === "LINK")
|
|
||||||
return (
|
|
||||||
<Modal toggleModal={toggleModal}>
|
|
||||||
<LinkModal
|
|
||||||
toggleLinkModal={toggleModal}
|
|
||||||
method={modal.method}
|
|
||||||
activeLink={modal.active as LinkIncludingShortenedCollectionAndTags}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
else if (modal && modal.modal === "COLLECTION")
|
|
||||||
return (
|
|
||||||
<Modal toggleModal={toggleModal}>
|
|
||||||
<CollectionModal
|
|
||||||
toggleCollectionModal={toggleModal}
|
|
||||||
method={modal.method}
|
|
||||||
isOwner={modal.isOwner as boolean}
|
|
||||||
defaultIndex={modal.defaultIndex}
|
|
||||||
activeCollection={
|
|
||||||
modal.active as CollectionIncludingMembersAndLinkCount
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
else return <></>;
|
|
||||||
}
|
|
|
@ -1,42 +1,38 @@
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { faPlus, faBars } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Dropdown from "@/components/Dropdown";
|
|
||||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import SearchBar from "@/components/SearchBar";
|
import SearchBar from "@/components/SearchBar";
|
||||||
import useAccountStore from "@/store/account";
|
import useAccountStore from "@/store/account";
|
||||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||||
import useModalStore from "@/store/modals";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||||
import ToggleDarkMode from "./ToggleDarkMode";
|
import ToggleDarkMode from "./ToggleDarkMode";
|
||||||
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
import NewLinkModal from "./ModalContent/NewLinkModal";
|
||||||
|
import NewCollectionModal from "./ModalContent/NewCollectionModal";
|
||||||
|
import Link from "next/link";
|
||||||
|
import UploadFileModal from "./ModalContent/UploadFileModal";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { setModal } = useModalStore();
|
const { settings, updateSettings } = useLocalSettingsStore();
|
||||||
|
|
||||||
const { account } = useAccountStore();
|
const { account } = useAccountStore();
|
||||||
|
|
||||||
const [profileDropdown, setProfileDropdown] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (theme === "dark") {
|
|
||||||
setTheme("light");
|
|
||||||
} else {
|
|
||||||
setTheme("dark");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [sidebar, setSidebar] = useState(false);
|
const [sidebar, setSidebar] = useState(false);
|
||||||
|
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (settings.theme === "dark") {
|
||||||
|
updateSettings({ theme: "light" });
|
||||||
|
} else {
|
||||||
|
updateSettings({ theme: "dark" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSidebar(false);
|
setSidebar(false);
|
||||||
}, [width]);
|
}, [width]);
|
||||||
|
@ -49,99 +45,140 @@ export default function Navbar() {
|
||||||
setSidebar(!sidebar);
|
setSidebar(!sidebar);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [newLinkModal, setNewLinkModal] = useState(false);
|
||||||
|
const [newCollectionModal, setNewCollectionModal] = useState(false);
|
||||||
|
const [uploadFileModal, setUploadFileModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-2 items-center px-5 py-2 border-solid border-b-sky-100 dark:border-b-neutral-700 border-b h-16">
|
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b">
|
||||||
<div
|
<div
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-[0.687rem] text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700"
|
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
|
<i className="bi-list text-2xl leading-none"></i>
|
||||||
</div>
|
</div>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<ToggleDarkMode className="hidden sm:inline-grid" />
|
||||||
onClick={() => {
|
|
||||||
setModal({
|
<div className="dropdown dropdown-end">
|
||||||
modal: "LINK",
|
<div className="tooltip tooltip-bottom" data-tip="Create New...">
|
||||||
state: true,
|
<div
|
||||||
method: "CREATE",
|
tabIndex={0}
|
||||||
});
|
role="button"
|
||||||
}}
|
className="flex min-w-[3.4rem] items-center btn btn-accent dark:border-violet-400 text-white btn-sm max-h-[2rem] px-2 relative"
|
||||||
className="inline-flex gap-1 relative sm:w-[7.2rem] items-center font-semibold select-none cursor-pointer p-[0.687rem] sm:p-2 sm:px-3 rounded-md sm:rounded-full hover:bg-sky-100 dark:hover:bg-sky-800 sm:dark:hover:bg-sky-600 text-sky-500 sm:text-white sm:bg-sky-700 sm:hover:bg-sky-600 duration-100 group"
|
>
|
||||||
>
|
<span>
|
||||||
<FontAwesomeIcon
|
<i className="bi-plus text-4xl absolute -top-[0.3rem] left-0 pointer-events-none"></i>
|
||||||
icon={faPlus}
|
</span>
|
||||||
className="w-5 h-5 sm:group-hover:ml-9 sm:absolute duration-100"
|
<span>
|
||||||
/>
|
<i className="bi-caret-down-fill text-xs absolute top-2 right-[0.3rem] pointer-events-none"></i>
|
||||||
<span className="hidden sm:block group-hover:opacity-0 text-right w-full duration-100">
|
</span>
|
||||||
New Link
|
</div>
|
||||||
</span>
|
</div>
|
||||||
|
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setNewLinkModal(true);
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
New Link
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/* <li>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setUploadFileModal(true);
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Upload File
|
||||||
|
</div>
|
||||||
|
</li> */}
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
setNewCollectionModal(true);
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
New Collection
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToggleDarkMode className="sm:flex hidden" />
|
<div className="dropdown dropdown-end">
|
||||||
|
<div tabIndex={0} role="button" className="btn btn-circle btn-ghost">
|
||||||
<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"
|
|
||||||
onClick={() => setProfileDropdown(!profileDropdown)}
|
|
||||||
id="profile-dropdown"
|
|
||||||
>
|
|
||||||
<ProfilePhoto
|
<ProfilePhoto
|
||||||
src={account.image ? account.image : undefined}
|
src={account.image ? account.image : undefined}
|
||||||
priority={true}
|
priority={true}
|
||||||
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
|
|
||||||
/>
|
/>
|
||||||
<p
|
|
||||||
id="profile-dropdown"
|
|
||||||
className="text-black dark:text-white leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
|
|
||||||
>
|
|
||||||
{account.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{profileDropdown ? (
|
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
|
||||||
<Dropdown
|
<li>
|
||||||
items={[
|
<Link
|
||||||
{
|
href="/settings/account"
|
||||||
name: "Settings",
|
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||||
href: "/settings/account",
|
tabIndex={0}
|
||||||
},
|
role="button"
|
||||||
{
|
|
||||||
name: `Switch to ${theme === "light" ? "Dark" : "Light"}`,
|
|
||||||
onClick: () => {
|
|
||||||
handleToggle();
|
|
||||||
setProfileDropdown(!profileDropdown);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Logout",
|
|
||||||
onClick: () => {
|
|
||||||
signOut();
|
|
||||||
setProfileDropdown(!profileDropdown);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onClickOutside={(e: Event) => {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
if (target.id !== "profile-dropdown") setProfileDropdown(false);
|
|
||||||
}}
|
|
||||||
className="absolute top-11 right-0 z-20 w-36"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{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">
|
Settings
|
||||||
<Sidebar />
|
</Link>
|
||||||
</div>
|
</li>
|
||||||
</ClickAwayHandler>
|
<li>
|
||||||
</div>
|
<div
|
||||||
) : null}
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
handleToggle();
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Switch to {settings.theme === "light" ? "Dark" : "Light"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
signOut();
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{sidebar ? (
|
||||||
|
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
|
||||||
|
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
|
||||||
|
<div className="slide-right h-full shadow-lg">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</ClickAwayHandler>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{newLinkModal ? (
|
||||||
|
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||||
|
) : undefined}
|
||||||
|
{newCollectionModal ? (
|
||||||
|
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
|
||||||
|
) : undefined}
|
||||||
|
{uploadFileModal ? (
|
||||||
|
<UploadFileModal onClose={() => setUploadFileModal(false)} />
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,40 +1,47 @@
|
||||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
import React, { useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import NewLinkModal from "./ModalContent/NewLinkModal";
|
||||||
import React from "react";
|
|
||||||
import useModalStore from "@/store/modals";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function NoLinksFound({ text }: Props) {
|
export default function NoLinksFound({ text }: Props) {
|
||||||
const { setModal } = useModalStore();
|
const [newLinkModal, setNewLinkModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<div className="w-full h-full flex flex-col justify-center p-3">
|
||||||
<p className="text-center text-2xl text-black dark:text-white">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5 text-primary drop-shadow"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
>
|
||||||
|
<path d="M9.752 6.193c.599.6 1.73.437 2.528-.362.798-.799.96-1.932.362-2.531-.599-.6-1.73-.438-2.528.361-.798.8-.96 1.933-.362 2.532" />
|
||||||
|
<path d="M15.811 3.312c-.363 1.534-1.334 3.626-3.64 6.218l-.24 2.408a2.56 2.56 0 0 1-.732 1.526L8.817 15.85a.51.51 0 0 1-.867-.434l.27-1.899c.04-.28-.013-.593-.131-.956a9.42 9.42 0 0 0-.249-.657l-.082-.202c-.815-.197-1.578-.662-2.191-1.277-.614-.615-1.079-1.379-1.275-2.195l-.203-.083a9.556 9.556 0 0 0-.655-.248c-.363-.119-.675-.172-.955-.132l-1.896.27A.51.51 0 0 1 .15 7.17l2.382-2.386c.41-.41.947-.67 1.524-.734h.006l2.4-.238C9.005 1.55 11.087.582 12.623.208c.89-.217 1.59-.232 2.08-.188.244.023.435.06.57.093.067.017.12.033.16.045.184.06.279.13.351.295l.029.073a3.475 3.475 0 0 1 .157.721c.055.485.051 1.178-.159 2.065Zm-4.828 7.475.04-.04-.107 1.081a1.536 1.536 0 0 1-.44.913l-1.298 1.3.054-.38c.072-.506-.034-.993-.172-1.418a8.548 8.548 0 0 0-.164-.45c.738-.065 1.462-.38 2.087-1.006ZM5.205 5c-.625.626-.94 1.351-1.004 2.09a8.497 8.497 0 0 0-.45-.164c-.424-.138-.91-.244-1.416-.172l-.38.054 1.3-1.3c.245-.246.566-.401.91-.44l1.08-.107-.04.039Zm9.406-3.961c-.38-.034-.967-.027-1.746.163-1.558.38-3.917 1.496-6.937 4.521-.62.62-.799 1.34-.687 2.051.107.676.483 1.362 1.048 1.928.564.565 1.25.941 1.924 1.049.71.112 1.429-.067 2.048-.688 3.079-3.083 4.192-5.444 4.556-6.987.183-.771.18-1.345.138-1.713a2.835 2.835 0 0 0-.045-.283 3.078 3.078 0 0 0-.3-.041Z" />
|
||||||
|
<path d="M7.009 12.139a7.632 7.632 0 0 1-1.804-1.352A7.568 7.568 0 0 1 3.794 8.86c-1.102.992-1.965 5.054-1.839 5.18.125.126 3.936-.896 5.054-1.902Z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-center text-xl sm:text-2xl">
|
||||||
{text || "You haven't created any Links Here"}
|
{text || "You haven't created any Links Here"}
|
||||||
</p>
|
</p>
|
||||||
<div className="text-center text-black dark:text-white w-full mt-4">
|
<p className="text-center text-sm sm:text-base">
|
||||||
|
Start your journey by creating a new Link!
|
||||||
|
</p>
|
||||||
|
<div className="text-center w-full mt-4">
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModal({
|
setNewLinkModal(true);
|
||||||
modal: "LINK",
|
|
||||||
state: true,
|
|
||||||
method: "CREATE",
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
className="inline-flex gap-1 relative w-[11.4rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-full dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100 group"
|
className="inline-flex gap-1 relative w-[11rem] items-center btn btn-accent dark:border-violet-400 text-white group"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
|
||||||
icon={faPlus}
|
|
||||||
className="w-5 h-5 group-hover:ml-[4.325rem] absolute duration-100"
|
|
||||||
/>
|
|
||||||
<span className="group-hover:opacity-0 text-right w-full duration-100">
|
<span className="group-hover:opacity-0 text-right w-full duration-100">
|
||||||
Create New Link
|
Create New Link
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{newLinkModal ? (
|
||||||
|
<NewLinkModal onClose={() => setNewLinkModal(false)} />
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<i
|
||||||
|
className={`${icon} text-primary text-3xl sm:text-4xl drop-shadow`}
|
||||||
|
></i>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl capitalize font-thin">{title}</p>
|
||||||
|
<p className="text-xs sm:text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import {
|
||||||
|
ArchivedFormat,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
format: ArchivedFormat;
|
||||||
|
activeLink: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
downloadable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PreservedFormatRow({
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
format,
|
||||||
|
activeLink,
|
||||||
|
downloadable,
|
||||||
|
}: Props) {
|
||||||
|
const session = useSession();
|
||||||
|
const { getLink } = useLinkStore();
|
||||||
|
|
||||||
|
const [link, setLink] =
|
||||||
|
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const data = await getLink(link.id as number, isPublic);
|
||||||
|
setLink(
|
||||||
|
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
let interval: any;
|
||||||
|
if (link?.image === "pending" || link?.pdf === "pending") {
|
||||||
|
interval = setInterval(async () => {
|
||||||
|
const data = await getLink(link.id as number, isPublic);
|
||||||
|
setLink(
|
||||||
|
(data as any).response as LinkIncludingShortenedCollectionAndTags
|
||||||
|
);
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [link?.image, link?.pdf, link?.readable]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
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.pdf ? "PDF" : "Screenshot";
|
||||||
|
link.click();
|
||||||
|
} else {
|
||||||
|
console.error("Failed to download file");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error:", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="bg-primary text-primary-content p-2 rounded-l-md">
|
||||||
|
<i className={`${icon} text-2xl`} />
|
||||||
|
</div>
|
||||||
|
<p>{name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{downloadable || false ? (
|
||||||
|
<div
|
||||||
|
onClick={() => handleDownload()}
|
||||||
|
className="btn btn-sm btn-square"
|
||||||
|
>
|
||||||
|
<i className="bi-cloud-arrow-down text-xl text-neutral" />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`${isPublic ? "/public" : ""}/preserved/${
|
||||||
|
link?.id
|
||||||
|
}?format=${format}`}
|
||||||
|
target="_blank"
|
||||||
|
className="btn btn-sm btn-square"
|
||||||
|
>
|
||||||
|
<i className="bi-box-arrow-up-right text-xl text-neutral" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,16 +1,21 @@
|
||||||
import React, { useEffect, useState } from "react";
|
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 Image from "next/image";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
src?: string;
|
src?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
emptyImage?: boolean;
|
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
|
name?: string;
|
||||||
|
large?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProfilePhoto({ src, className, priority }: Props) {
|
export default function ProfilePhoto({
|
||||||
|
src,
|
||||||
|
className,
|
||||||
|
priority,
|
||||||
|
name,
|
||||||
|
large,
|
||||||
|
}: Props) {
|
||||||
const [image, setImage] = useState("");
|
const [image, setImage] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -24,24 +29,36 @@ export default function ProfilePhoto({ src, className, priority }: Props) {
|
||||||
|
|
||||||
return !image ? (
|
return !image ? (
|
||||||
<div
|
<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={`avatar drop-shadow-md placeholder ${className || ""} ${
|
||||||
className || ""
|
large ? "w-28 h-28" : "w-8 h-8"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faUser} className="w-1/2 h-1/2 aspect-square" />
|
<div className="bg-base-100 text-neutral rounded-full w-full h-full ring-2 ring-neutral-content select-none">
|
||||||
|
{name ? (
|
||||||
|
<span className="text-2xl capitalize">{name.slice(0, 1)}</span>
|
||||||
|
) : (
|
||||||
|
<i className={`bi-person ${large ? "text-5xl" : "text-xl"}`}></i>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Image
|
<div
|
||||||
alt=""
|
className={`avatar skeleton rounded-full drop-shadow-md ${
|
||||||
src={image}
|
|
||||||
height={112}
|
|
||||||
width={112}
|
|
||||||
priority={priority}
|
|
||||||
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 || ""
|
className || ""
|
||||||
}`}
|
} ${large || "w-8 h-8"}`}
|
||||||
/>
|
>
|
||||||
|
<div className="rounded-full w-full h-full ring-2 ring-neutral-content">
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
src={image}
|
||||||
|
height={112}
|
||||||
|
width={112}
|
||||||
|
priority={priority}
|
||||||
|
draggable={false}
|
||||||
|
onError={() => setImage("")}
|
||||||
|
className="aspect-square rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,96 +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";
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { faCircle, faCircleCheck } from "@fortawesome/free-regular-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { ChangeEventHandler } from "react";
|
import { ChangeEventHandler } from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -18,17 +16,15 @@ export default function RadioButton({ label, state, onClick }: Props) {
|
||||||
checked={state}
|
checked={state}
|
||||||
onChange={onClick}
|
onChange={onClick}
|
||||||
/>
|
/>
|
||||||
<FontAwesomeIcon
|
{/*<FontAwesomeIcon*/}
|
||||||
icon={faCircleCheck}
|
{/* icon={faCircleCheck}*/}
|
||||||
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:block hidden"
|
{/* className="w-5 h-5 text-primary peer-checked:block hidden"*/}
|
||||||
/>
|
{/*/>*/}
|
||||||
<FontAwesomeIcon
|
{/*<FontAwesomeIcon*/}
|
||||||
icon={faCircle}
|
{/* icon={faCircle}*/}
|
||||||
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:hidden block"
|
{/* className="w-5 h-5 text-primary peer-checked:hidden block"*/}
|
||||||
/>
|
{/*/>*/}
|
||||||
<span className="text-black dark:text-white rounded select-none">
|
<span className="rounded select-none">{label}</span>
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,263 @@
|
||||||
|
import unescapeString from "@/lib/client/unescapeString";
|
||||||
|
import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
|
||||||
|
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||||
|
import useLinkStore from "@/store/links";
|
||||||
|
import {
|
||||||
|
ArchivedFormat,
|
||||||
|
LinkIncludingShortenedCollectionAndTags,
|
||||||
|
} from "@/types/global";
|
||||||
|
import ColorThief, { RGBColor } from "colorthief";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type LinkContent = {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
textContent: string;
|
||||||
|
length: number;
|
||||||
|
excerpt: string;
|
||||||
|
byline: string;
|
||||||
|
dir: string;
|
||||||
|
siteName: string;
|
||||||
|
lang: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReadableView({ link }: Props) {
|
||||||
|
const [linkContent, setLinkContent] = useState<LinkContent>();
|
||||||
|
const [imageError, setImageError] = useState<boolean>(false);
|
||||||
|
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
||||||
|
|
||||||
|
const colorThief = new ColorThief();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { links, getLink } = useLinkStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLinkContent = async () => {
|
||||||
|
if (router.query.id && readabilityAvailable(link)) {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/v1/archives/${link?.id}?format=${ArchivedFormat.readability}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await response?.json();
|
||||||
|
|
||||||
|
setLinkContent(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLinkContent();
|
||||||
|
}, [link]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (link) getLink(link?.id as number);
|
||||||
|
|
||||||
|
let interval: any;
|
||||||
|
if (
|
||||||
|
link &&
|
||||||
|
(link?.image === "pending" ||
|
||||||
|
link?.pdf === "pending" ||
|
||||||
|
link?.readable === "pending" ||
|
||||||
|
!link?.image ||
|
||||||
|
!link?.pdf ||
|
||||||
|
!link?.readable)
|
||||||
|
) {
|
||||||
|
interval = setInterval(() => getLink(link.id as number), 5000);
|
||||||
|
} else {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [link?.image, link?.pdf, link?.readable]);
|
||||||
|
|
||||||
|
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 bottom, ${rgbToHex(
|
||||||
|
colorPalette[0][0],
|
||||||
|
colorPalette[0][1],
|
||||||
|
colorPalette[0][2]
|
||||||
|
)}20, ${rgbToHex(
|
||||||
|
colorPalette[1][0],
|
||||||
|
colorPalette[1][1],
|
||||||
|
colorPalette[1][2]
|
||||||
|
)}20)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colorPalette[2] && colorPalette[3]) {
|
||||||
|
bannerInner.style.background = `linear-gradient(to bottom, ${rgbToHex(
|
||||||
|
colorPalette[2][0],
|
||||||
|
colorPalette[2][1],
|
||||||
|
colorPalette[2][2]
|
||||||
|
)}30, ${rgbToHex(
|
||||||
|
colorPalette[3][0],
|
||||||
|
colorPalette[3][1],
|
||||||
|
colorPalette[3][2]
|
||||||
|
)})30`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [colorPalette]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col max-w-screen-md h-full mx-auto py-5`}>
|
||||||
|
<div
|
||||||
|
id="link-banner"
|
||||||
|
className="link-banner bg-opacity-10 border-neutral-content p-3 border mb-3"
|
||||||
|
>
|
||||||
|
<div id="link-banner-inner" className="link-banner-inner"></div>
|
||||||
|
|
||||||
|
<div className={`flex flex-col gap-3 items-start`}>
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
{!imageError && link?.url && (
|
||||||
|
<Image
|
||||||
|
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
|
||||||
|
width={42}
|
||||||
|
height={42}
|
||||||
|
alt=""
|
||||||
|
id={"favicon-" + link.id}
|
||||||
|
className="bg-white shadow rounded-md p-1 select-none mt-1"
|
||||||
|
draggable="false"
|
||||||
|
onLoad={(e) => {
|
||||||
|
try {
|
||||||
|
const color = colorThief.getPalette(
|
||||||
|
e.target as HTMLImageElement,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
|
||||||
|
setColorPalette(color);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
setImageError(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-xl">
|
||||||
|
{unescapeString(
|
||||||
|
link?.name || link?.description || link?.url || ""
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{link?.url ? (
|
||||||
|
<Link
|
||||||
|
href={link?.url || ""}
|
||||||
|
title={link?.url}
|
||||||
|
target="_blank"
|
||||||
|
className="hover:opacity-60 duration-100 break-all text-sm flex items-center gap-1 text-neutral w-fit"
|
||||||
|
>
|
||||||
|
<i className="bi-link-45deg"></i>
|
||||||
|
|
||||||
|
{isValidUrl(link?.url || "")
|
||||||
|
? new URL(link?.url as string).host
|
||||||
|
: undefined}
|
||||||
|
</Link>
|
||||||
|
) : undefined}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className="bi-folder-fill drop-shadow text-2xl"
|
||||||
|
style={{ color: link?.collection.color }}
|
||||||
|
></i>
|
||||||
|
<p
|
||||||
|
title={link?.collection.name}
|
||||||
|
className="text-lg truncate max-w-[12rem]"
|
||||||
|
>
|
||||||
|
{link?.collection.name}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
{link?.tags.map((e, i) => (
|
||||||
|
<Link key={i} href={`/tags/${e.id}`} className="z-10">
|
||||||
|
<p
|
||||||
|
title={e.name}
|
||||||
|
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
|
||||||
|
>
|
||||||
|
#{e.name}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="min-w-fit text-sm text-neutral">
|
||||||
|
{link?.createdAt
|
||||||
|
? new Date(link?.createdAt).toLocaleString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: undefined}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{link?.name ? <p>{unescapeString(link?.description)}</p> : undefined}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5 h-full">
|
||||||
|
{link?.readable?.startsWith("archives") ? (
|
||||||
|
<div
|
||||||
|
className="line-break px-1 reader-view"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: DOMPurify.sanitize(linkContent?.content || "") || "",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`w-full h-full flex flex-col justify-center p-10 ${
|
||||||
|
link?.readable === "pending" || !link?.readable ? "skeleton" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="currentColor"
|
||||||
|
className="w-1/4 min-w-[7rem] max-w-[15rem] h-auto mx-auto mb-5"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
>
|
||||||
|
<path d="m14.12 10.163 1.715.858c.22.11.22.424 0 .534L8.267 15.34a.598.598 0 0 1-.534 0L.165 11.555a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.66zM7.733.063a.598.598 0 0 1 .534 0l7.568 3.784a.3.3 0 0 1 0 .535L8.267 8.165a.598.598 0 0 1-.534 0L.165 4.382a.299.299 0 0 1 0-.535L7.733.063z" />
|
||||||
|
<path d="m14.12 6.576 1.715.858c.22.11.22.424 0 .534l-7.568 3.784a.598.598 0 0 1-.534 0L.165 7.968a.299.299 0 0 1 0-.534l1.716-.858 5.317 2.659c.505.252 1.1.252 1.604 0l5.317-2.659z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-center text-2xl">
|
||||||
|
The Link preservation is currently in the queue
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-lg mt-2">
|
||||||
|
Please check back later to see the result
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,11 +0,0 @@
|
||||||
export default function RequiredBadge() {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
title="Required Field"
|
|
||||||
className="text-black dark:text-white cursor-help"
|
|
||||||
>
|
|
||||||
{" "}
|
|
||||||
*
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,42 +1,62 @@
|
||||||
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
|
import { useEffect, useState } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
export default function SearchBar() {
|
type Props = {
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SearchBar({ placeholder }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const routeQuery = router.query.q;
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(
|
useEffect(() => {
|
||||||
routeQuery ? decodeURIComponent(routeQuery as string) : ""
|
router.query.q
|
||||||
);
|
? setSearchQuery(decodeURIComponent(router.query.q as string))
|
||||||
|
: setSearchQuery("");
|
||||||
|
}, [router.query.q]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center relative group">
|
<div className="flex items-center relative group">
|
||||||
<label
|
<label
|
||||||
htmlFor="search-box"
|
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"
|
className="inline-flex items-center w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faMagnifyingGlass} className="w-5 h-5" />
|
<i className="bi-search"></i>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
id="search-box"
|
id="search-box"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search for Links"
|
placeholder={placeholder || "Search for Links"}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
e.target.value.includes("%") &&
|
e.target.value.includes("%") &&
|
||||||
toast.error("The search query should not contain '%'.");
|
toast.error("The search query should not contain '%'.");
|
||||||
setSearchQuery(e.target.value.replace("%", ""));
|
setSearchQuery(e.target.value.replace("%", ""));
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) =>
|
onKeyDown={(e) => {
|
||||||
e.key === "Enter" &&
|
if (e.key === "Enter") {
|
||||||
router.push("/search?q=" + encodeURIComponent(searchQuery))
|
if (router.pathname.startsWith("/public")) {
|
||||||
}
|
if (!searchQuery) {
|
||||||
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"
|
return router.push("/public/collections/" + router.query.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return router.push(
|
||||||
|
"/public/collections/" +
|
||||||
|
router.query.id +
|
||||||
|
"?q=" +
|
||||||
|
encodeURIComponent(searchQuery || ""),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return router.push(
|
||||||
|
"/search?q=" + encodeURIComponent(searchQuery),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:focus:w-80 md:w-[15rem] md:max-w-full duration-200 outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,27 +1,10 @@
|
||||||
import useCollectionStore from "@/store/collections";
|
import useCollectionStore from "@/store/collections";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
faUser,
|
|
||||||
faPalette,
|
|
||||||
faBoxArchive,
|
|
||||||
faKey,
|
|
||||||
faLock,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import {
|
|
||||||
faCircleQuestion,
|
|
||||||
faCreditCard,
|
|
||||||
} from "@fortawesome/free-regular-svg-icons";
|
|
||||||
import {
|
|
||||||
faGithub,
|
|
||||||
faMastodon,
|
|
||||||
faXTwitter,
|
|
||||||
} from "@fortawesome/free-brands-svg-icons";
|
|
||||||
|
|
||||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
const LINKWARDEN_VERSION = "v2.3.0";
|
const LINKWARDEN_VERSION = "v2.4.0";
|
||||||
|
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
@ -35,7 +18,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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={`bg-base-100 h-full w-64 overflow-y-auto border-solid border border-base-100 border-r-neutral-content p-5 z-20 flex flex-col gap-5 justify-between ${
|
||||||
className || ""
|
className || ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
@ -44,18 +27,13 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/settings/account`
|
active === `/settings/account`
|
||||||
? "bg-sky-500"
|
? "bg-primary/20"
|
||||||
: "hover:bg-slate-500"
|
: "hover:bg-neutral/20"
|
||||||
} 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`}
|
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-person text-primary text-2xl"></i>
|
||||||
icon={faUser}
|
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
<p className="truncate w-full pr-7">Account</p>
|
||||||
Account
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
@ -63,18 +41,13 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/settings/appearance`
|
active === `/settings/appearance`
|
||||||
? "bg-sky-500"
|
? "bg-primary/20"
|
||||||
: "hover:bg-slate-500"
|
: "hover:bg-neutral/20"
|
||||||
} 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`}
|
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-palette text-primary text-2xl"></i>
|
||||||
icon={faPalette}
|
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
<p className="truncate w-full pr-7">Appearance</p>
|
||||||
Appearance
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
@ -82,35 +55,25 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/settings/archive`
|
active === `/settings/archive`
|
||||||
? "bg-sky-500"
|
? "bg-primary/20"
|
||||||
: "hover:bg-slate-500"
|
: "hover:bg-neutral/20"
|
||||||
} 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`}
|
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-archive text-primary text-2xl"></i>
|
||||||
icon={faBoxArchive}
|
<p className="truncate w-full pr-7">Archive</p>
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
|
||||||
Archive
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/api">
|
<Link href="/settings/api">
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/settings/api` ? "bg-sky-500" : "hover:bg-slate-500"
|
active === `/settings/api`
|
||||||
} 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`}
|
? "bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-key text-primary text-2xl"></i>
|
||||||
icon={faKey}
|
<p className="truncate w-full pr-7">API Keys</p>
|
||||||
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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
@ -118,18 +81,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/settings/password`
|
active === `/settings/password`
|
||||||
? "bg-sky-500"
|
? "bg-primary/20"
|
||||||
: "hover:bg-slate-500"
|
: "hover:bg-neutral/20"
|
||||||
} 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`}
|
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-lock text-primary text-2xl"></i>
|
||||||
icon={faLock}
|
<p className="truncate w-full pr-7">Password</p>
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
|
||||||
Password
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
@ -138,18 +95,12 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/settings/billing`
|
active === `/settings/billing`
|
||||||
? "bg-sky-500"
|
? "bg-primary/20"
|
||||||
: "hover:bg-slate-500"
|
: "hover:bg-neutral/20"
|
||||||
} 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`}
|
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-credit-card text-primary text-xl"></i>
|
||||||
icon={faCreditCard}
|
<p className="truncate w-full pr-7">Billing</p>
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
|
||||||
Billing
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
@ -159,67 +110,44 @@ export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
<Link
|
<Link
|
||||||
href={`https://github.com/linkwarden/linkwarden/releases`}
|
href={`https://github.com/linkwarden/linkwarden/releases`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="dark:text-gray-300 text-gray-500 text-sm ml-2 hover:opacity-50 duration-100"
|
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
|
||||||
>
|
>
|
||||||
Linkwarden {LINKWARDEN_VERSION}
|
Linkwarden {LINKWARDEN_VERSION}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://docs.linkwarden.app" target="_blank">
|
<Link href="https://docs.linkwarden.app" target="_blank">
|
||||||
<div
|
<div
|
||||||
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-question-circle text-primary text-xl"></i>
|
||||||
icon={faCircleQuestion as any}
|
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
<p className="truncate w-full pr-7">Help</p>
|
||||||
Help
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
|
<Link href="https://github.com/linkwarden/linkwarden" target="_blank">
|
||||||
<div
|
<div
|
||||||
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-github text-primary text-xl"></i>
|
||||||
icon={faGithub as any}
|
<p className="truncate w-full pr-7">GitHub</p>
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
|
||||||
GitHub
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
|
<Link href="https://twitter.com/LinkwardenHQ" target="_blank">
|
||||||
<div
|
<div
|
||||||
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-twitter-x text-primary text-xl"></i>
|
||||||
icon={faXTwitter as any}
|
<p className="truncate w-full pr-7">Twitter</p>
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
|
||||||
Twitter
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
|
<Link href="https://fosstodon.org/@linkwarden" target="_blank">
|
||||||
<div
|
<div
|
||||||
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-mastodon text-primary text-xl"></i>
|
||||||
icon={faMastodon as any}
|
<p className="truncate w-full pr-7">Mastodon</p>
|
||||||
className="w-6 h-6 text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-black dark:text-white truncate w-full pr-7">
|
|
||||||
Mastodon
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,19 +1,10 @@
|
||||||
import useCollectionStore from "@/store/collections";
|
import useCollectionStore from "@/store/collections";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import {
|
|
||||||
faFolder,
|
|
||||||
faHashtag,
|
|
||||||
faChartSimple,
|
|
||||||
faChevronDown,
|
|
||||||
faLink,
|
|
||||||
faGlobe,
|
|
||||||
faThumbTack,
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import useTagStore from "@/store/tags";
|
import useTagStore from "@/store/tags";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
|
import SidebarHighlightLink from "@/components/SidebarHighlightLink";
|
||||||
|
|
||||||
export default function Sidebar({ className }: { className?: string }) {
|
export default function Sidebar({ className }: { className?: string }) {
|
||||||
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
|
||||||
|
@ -52,74 +43,36 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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 ${
|
id="sidebar"
|
||||||
|
className={`bg-base-200 h-full w-72 lg:w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${
|
||||||
className || ""
|
className || ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Link href={`/dashboard`}>
|
<SidebarHighlightLink
|
||||||
<div
|
title={"Dashboard"}
|
||||||
className={`${
|
href={`/dashboard`}
|
||||||
active === `/dashboard` ? "bg-sky-500" : "hover:bg-slate-500"
|
icon={"bi-house"}
|
||||||
} 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`}
|
active={active === `/dashboard`}
|
||||||
>
|
/>
|
||||||
<FontAwesomeIcon
|
<SidebarHighlightLink
|
||||||
icon={faChartSimple}
|
title={"Pinned"}
|
||||||
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
|
href={`/links/pinned`}
|
||||||
/>
|
icon={"bi-pin-angle"}
|
||||||
<p className="text-black dark:text-white truncate w-full">
|
active={active === `/links/pinned`}
|
||||||
Dashboard
|
/>
|
||||||
</p>
|
<SidebarHighlightLink
|
||||||
</div>
|
title={"All Links"}
|
||||||
</Link>
|
href={`/links`}
|
||||||
|
icon={"bi-link-45deg"}
|
||||||
<Link href={`/links`}>
|
active={active === `/links`}
|
||||||
<div
|
/>
|
||||||
className={`${
|
<SidebarHighlightLink
|
||||||
active === `/links` ? "bg-sky-500" : "hover:bg-slate-500"
|
title={"All Collections"}
|
||||||
} 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`}
|
href={`/collections`}
|
||||||
>
|
icon={"bi-folder"}
|
||||||
<FontAwesomeIcon
|
active={active === `/collections`}
|
||||||
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`}>
|
|
||||||
<div
|
|
||||||
className={`${
|
|
||||||
active === `/collections` ? "bg-sky-500" : "hover:bg-slate-500"
|
|
||||||
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faFolder}
|
|
||||||
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
<p className="text-black dark:text-white truncate w-full">
|
|
||||||
All Collections
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href={`/links/pinned`}>
|
|
||||||
<div
|
|
||||||
className={`${
|
|
||||||
active === `/links/pinned` ? "bg-sky-500" : "hover:bg-slate-500"
|
|
||||||
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faThumbTack}
|
|
||||||
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500"
|
|
||||||
/>
|
|
||||||
<p className="text-black dark:text-white truncate w-full">
|
|
||||||
Pinned Links
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Disclosure defaultOpen={collectionDisclosure}>
|
<Disclosure defaultOpen={collectionDisclosure}>
|
||||||
|
@ -127,16 +80,14 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCollectionDisclosure(!collectionDisclosure);
|
setCollectionDisclosure(!collectionDisclosure);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-gray-500 dark:text-gray-300 mt-5"
|
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
|
||||||
>
|
>
|
||||||
<p>Collections</p>
|
<p className="text-sm">Collections</p>
|
||||||
|
<i
|
||||||
<FontAwesomeIcon
|
className={`bi-chevron-down ${
|
||||||
icon={faChevronDown}
|
|
||||||
className={`w-3 h-3 ${
|
|
||||||
collectionDisclosure ? "rotate-reverse" : "rotate"
|
collectionDisclosure ? "rotate-reverse" : "rotate"
|
||||||
}`}
|
}`}
|
||||||
/>
|
></i>
|
||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
<Transition
|
<Transition
|
||||||
enter="transition duration-100 ease-out"
|
enter="transition duration-100 ease-out"
|
||||||
|
@ -156,27 +107,23 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/collections/${e.id}`
|
active === `/collections/${e.id}`
|
||||||
? "bg-sky-500"
|
? "bg-primary/20"
|
||||||
: "hover:bg-slate-500"
|
: "hover:bg-neutral/20"
|
||||||
} 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`}
|
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i
|
||||||
icon={faFolder}
|
className="bi-folder-fill text-2xl drop-shadow"
|
||||||
className="w-6 h-6 drop-shadow"
|
|
||||||
style={{ color: e.color }}
|
style={{ color: e.color }}
|
||||||
/>
|
></i>
|
||||||
<p className="text-black dark:text-white truncate w-full">
|
<p className="truncate w-full">{e.name}</p>
|
||||||
{e.name}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{e.isPublic ? (
|
{e.isPublic ? (
|
||||||
<FontAwesomeIcon
|
<i
|
||||||
icon={faGlobe}
|
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
|
||||||
title="This collection is being shared publicly."
|
title="This collection is being shared publicly."
|
||||||
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300"
|
></i>
|
||||||
/>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs">
|
<div className="drop-shadow text-neutral text-xs">
|
||||||
{e._count?.links}
|
{e._count?.links}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -187,7 +134,7 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
||||||
>
|
>
|
||||||
<p className="text-gray-500 dark:text-gray-300 text-xs font-semibold truncate w-full pr-7">
|
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
|
||||||
You Have No Collections...
|
You Have No Collections...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -200,13 +147,14 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTagDisclosure(!tagDisclosure);
|
setTagDisclosure(!tagDisclosure);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-gray-500 dark:text-gray-300 mt-5"
|
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
|
||||||
>
|
>
|
||||||
<p>Tags</p>
|
<p className="text-sm">Tags</p>
|
||||||
<FontAwesomeIcon
|
<i
|
||||||
icon={faChevronDown}
|
className={`bi-chevron-down ${
|
||||||
className={`w-3 h-3 ${tagDisclosure ? "rotate-reverse" : "rotate"}`}
|
tagDisclosure ? "rotate-reverse" : "rotate"
|
||||||
/>
|
}`}
|
||||||
|
></i>
|
||||||
</Disclosure.Button>
|
</Disclosure.Button>
|
||||||
<Transition
|
<Transition
|
||||||
enter="transition duration-100 ease-out"
|
enter="transition duration-100 ease-out"
|
||||||
|
@ -226,19 +174,13 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
active === `/tags/${e.id}`
|
active === `/tags/${e.id}`
|
||||||
? "bg-sky-500"
|
? "bg-primary/20"
|
||||||
: "hover:bg-slate-500"
|
: "hover:bg-neutral/20"
|
||||||
} 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`}
|
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<i className="bi-hash text-2xl text-primary drop-shadow"></i>
|
||||||
icon={faHashtag}
|
<p className="truncate w-full pr-7">{e.name}</p>
|
||||||
className="w-4 h-4 text-sky-500 dark:text-sky-500 mt-1"
|
<div className="drop-shadow text-neutral text-xs">
|
||||||
/>
|
|
||||||
|
|
||||||
<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}
|
{e._count?.links}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -249,7 +191,7 @@ export default function Sidebar({ className }: { className?: string }) {
|
||||||
<div
|
<div
|
||||||
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
|
||||||
>
|
>
|
||||||
<p className="text-gray-500 dark:text-gray-300 text-xs font-semibold truncate w-full pr-7">
|
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
|
||||||
You Have No Tags...
|
You Have No Tags...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function SidebarHighlightLink({
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
icon,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon: string;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link href={href}>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
active || false
|
||||||
|
? "bg-primary/20"
|
||||||
|
: "bg-neutral-content/20 hover:bg-neutral/20"
|
||||||
|
} duration-200 px-3 py-2 cursor-pointer gap-2 w-full rounded-lg capitalize`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"w-10 h-10 inline-flex items-center justify-center bg-black/10 dark:bg-white/5 rounded-full"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i className={`${icon} text-primary text-2xl drop-shadow`}></i>
|
||||||
|
</div>
|
||||||
|
<div className={"mt-1"}>
|
||||||
|
<p className="truncate w-full font-semibold text-sm">{title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,68 +1,127 @@
|
||||||
import React, { Dispatch, SetStateAction } from "react";
|
import React, { Dispatch, SetStateAction } from "react";
|
||||||
import ClickAwayHandler from "./ClickAwayHandler";
|
|
||||||
import RadioButton from "./RadioButton";
|
|
||||||
import { Sort } from "@/types/global";
|
import { Sort } from "@/types/global";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sortBy: Sort;
|
sortBy: Sort;
|
||||||
setSort: Dispatch<SetStateAction<Sort>>;
|
setSort: Dispatch<SetStateAction<Sort>>;
|
||||||
|
|
||||||
toggleSortDropdown: Function;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SortDropdown({
|
export default function SortDropdown({ sortBy, setSort }: Props) {
|
||||||
sortBy,
|
|
||||||
toggleSortDropdown,
|
|
||||||
setSort,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<ClickAwayHandler
|
<div className="dropdown dropdown-bottom dropdown-end">
|
||||||
onClickOutside={(e: Event) => {
|
<div
|
||||||
const target = e.target as HTMLInputElement;
|
tabIndex={0}
|
||||||
if (target.id !== "sort-dropdown") toggleSortDropdown();
|
role="button"
|
||||||
}}
|
className="btn btn-sm btn-square btn-ghost"
|
||||||
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"
|
>
|
||||||
>
|
<i className="bi-chevron-expand text-neutral text-2xl"></i>
|
||||||
<p className="mb-2 text-black dark:text-white text-center font-semibold">
|
|
||||||
Sort by
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<RadioButton
|
|
||||||
label="Date (Newest First)"
|
|
||||||
state={sortBy === Sort.DateNewestFirst}
|
|
||||||
onClick={() => setSort(Sort.DateNewestFirst)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RadioButton
|
|
||||||
label="Date (Oldest First)"
|
|
||||||
state={sortBy === Sort.DateOldestFirst}
|
|
||||||
onClick={() => setSort(Sort.DateOldestFirst)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RadioButton
|
|
||||||
label="Name (A-Z)"
|
|
||||||
state={sortBy === Sort.NameAZ}
|
|
||||||
onClick={() => setSort(Sort.NameAZ)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RadioButton
|
|
||||||
label="Name (Z-A)"
|
|
||||||
state={sortBy === Sort.NameZA}
|
|
||||||
onClick={() => setSort(Sort.NameZA)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RadioButton
|
|
||||||
label="Description (A-Z)"
|
|
||||||
state={sortBy === Sort.DescriptionAZ}
|
|
||||||
onClick={() => setSort(Sort.DescriptionAZ)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RadioButton
|
|
||||||
label="Description (Z-A)"
|
|
||||||
state={sortBy === Sort.DescriptionZA}
|
|
||||||
onClick={() => setSort(Sort.DescriptionZA)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ClickAwayHandler>
|
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1">
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Date (Newest First)"
|
||||||
|
checked={sortBy === Sort.DateNewestFirst}
|
||||||
|
onChange={() => {
|
||||||
|
setSort(Sort.DateNewestFirst);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Date (Newest First)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Date (Oldest First)"
|
||||||
|
checked={sortBy === Sort.DateOldestFirst}
|
||||||
|
onChange={() => setSort(Sort.DateOldestFirst)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Date (Oldest First)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Name (A-Z)"
|
||||||
|
checked={sortBy === Sort.NameAZ}
|
||||||
|
onChange={() => setSort(Sort.NameAZ)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Name (A-Z)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Name (Z-A)"
|
||||||
|
checked={sortBy === Sort.NameZA}
|
||||||
|
onChange={() => setSort(Sort.NameZA)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Name (Z-A)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Description (A-Z)"
|
||||||
|
checked={sortBy === Sort.DescriptionAZ}
|
||||||
|
onChange={() => setSort(Sort.DescriptionAZ)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Description (A-Z)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
className="label cursor-pointer flex justify-start"
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort-radio"
|
||||||
|
className="radio checked:bg-primary"
|
||||||
|
value="Description (Z-A)"
|
||||||
|
checked={sortBy === Sort.DescriptionZA}
|
||||||
|
onChange={() => setSort(Sort.DescriptionZA)}
|
||||||
|
/>
|
||||||
|
<span className="label-text">Description (Z-A)</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { IconProp } from "@fortawesome/fontawesome-svg-core";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onClick?: Function;
|
onClick?: Function;
|
||||||
icon?: IconProp;
|
|
||||||
label: string;
|
label: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -12,7 +8,6 @@ type Props = {
|
||||||
|
|
||||||
export default function SubmitButton({
|
export default function SubmitButton({
|
||||||
onClick,
|
onClick,
|
||||||
icon,
|
|
||||||
label,
|
label,
|
||||||
loading,
|
loading,
|
||||||
className,
|
className,
|
||||||
|
@ -21,17 +16,14 @@ export default function SubmitButton({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type ? type : undefined}
|
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 ${
|
className={`btn btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2 ${
|
||||||
loading
|
className || ""
|
||||||
? "bg-sky-600 cursor-auto"
|
}`}
|
||||||
: "bg-sky-700 hover:bg-sky-600 cursor-pointer"
|
|
||||||
} ${className || ""}`}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!loading && onClick) onClick();
|
if (!loading && onClick) onClick();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon && <FontAwesomeIcon icon={icon} className="h-5" />}
|
<p>{label}</p>
|
||||||
<p className="text-center w-full">{label}</p>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ export default function TextInput({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onKeyDown={onKeyDown}
|
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={`w-full rounded-md p-2 border-neutral-content border-solid border outline-none focus:border-primary duration-100 ${
|
||||||
className || ""
|
className || ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,31 +1,40 @@
|
||||||
import { useTheme } from "next-themes";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { useEffect, useState } from "react";
|
||||||
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ToggleDarkMode({ className }: Props) {
|
export default function ToggleDarkMode({ className }: Props) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { settings, updateSettings } = useLocalSettingsStore();
|
||||||
|
|
||||||
const handleToggle = () => {
|
const [theme, setTheme] = useState(localStorage.getItem("theme"));
|
||||||
if (theme === "dark") {
|
|
||||||
setTheme("light");
|
const handleToggle = (e: any) => {
|
||||||
} else {
|
setTheme(e.target.checked ? "dark" : "light");
|
||||||
setTheme("dark");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSettings({ theme: theme as string });
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
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}`}
|
className="tooltip tooltip-bottom"
|
||||||
onClick={handleToggle}
|
data-tip={`Switch to ${settings.theme === "light" ? "Dark" : "Light"}`}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon
|
<label
|
||||||
icon={theme === "dark" ? faSun : faMoon}
|
className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}
|
||||||
className="w-1/2 h-1/2 text-sky-600 group-hover:text-sky-500"
|
>
|
||||||
/>
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onChange={handleToggle}
|
||||||
|
className="theme-controller"
|
||||||
|
checked={localStorage.getItem("theme") === "light" ? false : true}
|
||||||
|
/>
|
||||||
|
<i className="bi-sun-fill text-xl swap-on"></i>
|
||||||
|
<i className="bi-moon-fill text-xl swap-off"></i>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||||
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
|
||||||
|
import { ViewMode } from "@/types/global";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
viewMode: string;
|
||||||
|
setViewMode: Dispatch<SetStateAction<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ViewDropdown({ viewMode, setViewMode }: Props) {
|
||||||
|
const { updateSettings } = useLocalSettingsStore();
|
||||||
|
|
||||||
|
const onChangeViewMode = (
|
||||||
|
e: React.MouseEvent<HTMLButtonElement>,
|
||||||
|
viewMode: ViewMode
|
||||||
|
) => {
|
||||||
|
setViewMode(viewMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateSettings({ viewMode: viewMode as ViewMode });
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-1 flex flex-row gap-1 border border-neutral-content rounded-[0.625rem]">
|
||||||
|
<button
|
||||||
|
onClick={(e) => onChangeViewMode(e, ViewMode.Card)}
|
||||||
|
className={`btn btn-square btn-sm btn-ghost ${
|
||||||
|
viewMode == ViewMode.Card
|
||||||
|
? "bg-primary/20 hover:bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="bi-grid w-4 h-4 text-neutral"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => onChangeViewMode(e, ViewMode.List)}
|
||||||
|
className={`btn btn-square btn-sm btn-ghost ${
|
||||||
|
viewMode == ViewMode.List
|
||||||
|
? "bg-primary/20 hover:bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="bi bi-view-stacked w-4 h-4 text-neutral"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* <button
|
||||||
|
onClick={(e) => onChangeViewMode(e, ViewMode.Grid)}
|
||||||
|
className={`btn btn-square btn-sm btn-ghost ${
|
||||||
|
viewMode == ViewMode.Grid
|
||||||
|
? "bg-primary/20 hover:bg-primary/20"
|
||||||
|
: "hover:bg-neutral/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="bi-columns-gap w-4 h-4 text-neutral"></i>
|
||||||
|
</button> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import { useEffect } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import useTagStore from "@/store/tags";
|
import useTagStore from "@/store/tags";
|
||||||
import useAccountStore from "@/store/account";
|
import useAccountStore from "@/store/account";
|
||||||
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
|
|
||||||
export default function useInitialData() {
|
export default function useInitialData() {
|
||||||
const { status, data } = useSession();
|
const { status, data } = useSession();
|
||||||
|
@ -10,10 +11,12 @@ export default function useInitialData() {
|
||||||
const { setTags } = useTagStore();
|
const { setTags } = useTagStore();
|
||||||
// const { setLinks } = useLinkStore();
|
// const { setLinks } = useLinkStore();
|
||||||
const { account, setAccount } = useAccountStore();
|
const { account, setAccount } = useAccountStore();
|
||||||
|
const { setSettings } = useLocalSettingsStore();
|
||||||
|
|
||||||
// Get account info
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setSettings();
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
|
// Get account info
|
||||||
setAccount(data?.user.id as number);
|
setAccount(data?.user.id as number);
|
||||||
}
|
}
|
||||||
}, [status, data]);
|
}, [status, data]);
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { RefObject, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export default function useOnScreen(ref: RefObject<HTMLElement>) {
|
||||||
|
const [isIntersecting, setIntersecting] = useState(false);
|
||||||
|
|
||||||
|
const observer = useMemo(
|
||||||
|
() =>
|
||||||
|
new IntersectionObserver(([entry]) =>
|
||||||
|
setIntersecting(entry.isIntersecting)
|
||||||
|
),
|
||||||
|
[ref]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
observer.observe(ref.current as HTMLElement);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isIntersecting;
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { useTheme } from "next-themes";
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode, useEffect } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
text?: string;
|
text?: string;
|
||||||
|
@ -9,44 +9,29 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CenteredForm({ text, children }: Props) {
|
export default function CenteredForm({ text, children }: Props) {
|
||||||
const { theme } = useTheme();
|
const { settings } = useLocalSettingsStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5">
|
<div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5">
|
||||||
<div className="m-auto flex flex-col gap-2 w-full">
|
<div className="m-auto flex flex-col gap-2 w-full">
|
||||||
{theme ? (
|
{settings.theme ? (
|
||||||
<Image
|
<Image
|
||||||
src={`/linkwarden_${theme === "dark" ? "dark" : "light"}.png`}
|
src={`/linkwarden_${
|
||||||
|
settings.theme === "dark" ? "dark" : "light"
|
||||||
|
}.png`}
|
||||||
width={640}
|
width={640}
|
||||||
height={136}
|
height={136}
|
||||||
alt="Linkwarden"
|
alt="Linkwarden"
|
||||||
className="h-12 w-fit mx-auto"
|
className="h-12 w-fit mx-auto"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{/* {theme === "dark" ? (
|
|
||||||
<Image
|
|
||||||
src="/linkwarden_dark.png"
|
|
||||||
width={640}
|
|
||||||
height={136}
|
|
||||||
alt="Linkwarden"
|
|
||||||
className="h-12 w-fit mx-auto"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Image
|
|
||||||
src="/linkwarden_light.png"
|
|
||||||
width={640}
|
|
||||||
height={136}
|
|
||||||
alt="Linkwarden"
|
|
||||||
className="h-12 w-fit mx-auto"
|
|
||||||
/>
|
|
||||||
)} */}
|
|
||||||
{text ? (
|
{text ? (
|
||||||
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold text-black dark:text-white px-2 text-center">
|
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center">
|
||||||
{text}
|
{text}
|
||||||
</p>
|
</p>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{children}
|
{children}
|
||||||
<p className="text-center text-xs text-gray-500 mb-5 dark:text-gray-400">
|
<p className="text-center text-xs text-neutral mb-5">
|
||||||
© {new Date().getFullYear()}{" "}
|
© {new Date().getFullYear()}{" "}
|
||||||
<Link href="https://linkwarden.app" className="font-semibold">
|
<Link href="https://linkwarden.app" className="font-semibold">
|
||||||
Linkwarden
|
Linkwarden
|
||||||
|
|
|
@ -1,207 +0,0 @@
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -2,8 +2,6 @@ import Navbar from "@/components/Navbar";
|
||||||
import AnnouncementBar from "@/components/AnnouncementBar";
|
import AnnouncementBar from "@/components/AnnouncementBar";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { ReactNode, useEffect, useState } from "react";
|
||||||
import ModalManagement from "@/components/ModalManagement";
|
|
||||||
import useModalStore from "@/store/modals";
|
|
||||||
import getLatestVersion from "@/lib/client/getLatestVersion";
|
import getLatestVersion from "@/lib/client/getLatestVersion";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -11,14 +9,6 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MainLayout({ children }: Props) {
|
export default function MainLayout({ children }: Props) {
|
||||||
const { modal } = useModalStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
modal
|
|
||||||
? (document.body.style.overflow = "hidden")
|
|
||||||
: (document.body.style.overflow = "auto");
|
|
||||||
}, [modal]);
|
|
||||||
|
|
||||||
const showAnnouncementBar = localStorage.getItem("showAnnouncementBar");
|
const showAnnouncementBar = localStorage.getItem("showAnnouncementBar");
|
||||||
const [showAnnouncement, setShowAnnouncement] = useState(
|
const [showAnnouncement, setShowAnnouncement] = useState(
|
||||||
showAnnouncementBar ? showAnnouncementBar === "true" : true
|
showAnnouncementBar ? showAnnouncementBar === "true" : true
|
||||||
|
@ -44,8 +34,6 @@ export default function MainLayout({ children }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ModalManagement />
|
|
||||||
|
|
||||||
{showAnnouncement ? (
|
{showAnnouncement ? (
|
||||||
<AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} />
|
<AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
@ -60,7 +48,7 @@ export default function MainLayout({ children }: Props) {
|
||||||
<div
|
<div
|
||||||
className={`w-full flex flex-col min-h-${
|
className={`w-full flex flex-col min-h-${
|
||||||
showAnnouncement ? "full" : "screen"
|
showAnnouncement ? "full" : "screen"
|
||||||
} lg:ml-64 xl:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
|
} lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
|
||||||
>
|
>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
import SettingsSidebar from "@/components/SettingsSidebar";
|
import SettingsSidebar from "@/components/SettingsSidebar";
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import React, { ReactNode, useEffect, useState } from "react";
|
||||||
import ModalManagement from "@/components/ModalManagement";
|
|
||||||
import useModalStore from "@/store/modals";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
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 Link from "next/link";
|
||||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||||
|
|
||||||
|
@ -14,16 +10,8 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: Props) {
|
export default function SettingsLayout({ children }: Props) {
|
||||||
const { modal } = useModalStore();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
modal
|
|
||||||
? (document.body.style.overflow = "hidden")
|
|
||||||
: (document.body.style.overflow = "auto");
|
|
||||||
}, [modal]);
|
|
||||||
|
|
||||||
const [sidebar, setSidebar] = useState(false);
|
const [sidebar, setSidebar] = useState(false);
|
||||||
|
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
@ -42,8 +30,6 @@ export default function SettingsLayout({ children }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ModalManagement />
|
|
||||||
|
|
||||||
<div className="flex max-w-screen-md mx-auto">
|
<div className="flex max-w-screen-md mx-auto">
|
||||||
<div className="hidden lg:block fixed h-screen">
|
<div className="hidden lg:block fixed h-screen">
|
||||||
<SettingsSidebar />
|
<SettingsSidebar />
|
||||||
|
@ -53,23 +39,23 @@ export default function SettingsLayout({ children }: Props) {
|
||||||
<div className="gap-2 inline-flex mr-3">
|
<div className="gap-2 inline-flex mr-3">
|
||||||
<div
|
<div
|
||||||
onClick={toggleSidebar}
|
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"
|
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBars} className="w-5 h-5" />
|
<i className="bi-list text-2xl leading-none"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
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"
|
className="text-neutral btn btn-square btn-sm btn-ghost"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5" />
|
<i className="bi-chevron-left text-xl"></i>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
{sidebar ? (
|
{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">
|
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
|
||||||
<ClickAwayHandler
|
<ClickAwayHandler
|
||||||
className="h-full"
|
className="h-full"
|
||||||
onClickOutside={toggleSidebar}
|
onClickOutside={toggleSidebar}
|
||||||
|
|
|
@ -1,151 +0,0 @@
|
||||||
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 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) {
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
const context = await browser.newContext(devices["Desktop Chrome"]);
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
||||||
|
|
||||||
const content = await page.content();
|
|
||||||
|
|
||||||
// Readability
|
|
||||||
|
|
||||||
const window = new JSDOM("").window;
|
|
||||||
const purify = DOMPurify(window);
|
|
||||||
const cleanedUpContent = purify.sanitize(content);
|
|
||||||
const dom = new JSDOM(cleanedUpContent, { url: url });
|
|
||||||
const article = new Readability(dom.window.document).parse();
|
|
||||||
|
|
||||||
const articleText = article?.textContent
|
|
||||||
.replace(/ +(?= )/g, "") // strip out multiple spaces
|
|
||||||
.replace(/(\r\n|\n|\r)/gm, " "); // strip out line breaks
|
|
||||||
|
|
||||||
await createFile({
|
|
||||||
data: JSON.stringify(article),
|
|
||||||
filePath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.link.update({
|
|
||||||
where: { id: linkId },
|
|
||||||
data: {
|
|
||||||
readabilityPath: `archives/${targetLink.collectionId}/${linkId}_readability.json`,
|
|
||||||
textContent: articleText,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Screenshot/PDF
|
|
||||||
|
|
||||||
let faulty = false;
|
|
||||||
await page
|
|
||||||
.evaluate(autoScroll, Number(process.env.AUTOSCROLL_TIMEOUT) || 30)
|
|
||||||
.catch((e) => (faulty = true));
|
|
||||||
|
|
||||||
const linkExists = await prisma.link.findUnique({
|
|
||||||
where: { id: linkId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (linkExists && !faulty) {
|
|
||||||
if (user.archiveAsScreenshot) {
|
|
||||||
const screenshot = await page.screenshot({ fullPage: true });
|
|
||||||
await createFile({
|
|
||||||
data: screenshot,
|
|
||||||
filePath: `archives/${linkExists.collectionId}/${linkId}.png`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.archiveAsPDF) {
|
|
||||||
const pdf = await page.pdf({
|
|
||||||
width: "1366px",
|
|
||||||
height: "1931px",
|
|
||||||
printBackground: true,
|
|
||||||
margin: { top: "15px", bottom: "15px" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await createFile({
|
|
||||||
data: pdf,
|
|
||||||
filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.link.update({
|
|
||||||
where: { id: linkId },
|
|
||||||
data: {
|
|
||||||
screenshotPath: user.archiveAsScreenshot
|
|
||||||
? `archives/${linkExists.collectionId}/${linkId}.png`
|
|
||||||
: null,
|
|
||||||
pdfPath: user.archiveAsPDF
|
|
||||||
? `archives/${linkExists.collectionId}/${linkId}.pdf`
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (faulty) {
|
|
||||||
await prisma.link.update({
|
|
||||||
where: { id: linkId },
|
|
||||||
data: {
|
|
||||||
screenshotPath: null,
|
|
||||||
pdfPath: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
|
|
||||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
reject(new Error(`Webpage was too long to be archived.`));
|
|
||||||
}, AUTOSCROLL_TIMEOUT * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
const scrollingPromise = new Promise<void>((resolve) => {
|
|
||||||
let totalHeight = 0;
|
|
||||||
let distance = 100;
|
|
||||||
let scrollDown = setInterval(() => {
|
|
||||||
let scrollHeight = document.body.scrollHeight;
|
|
||||||
window.scrollBy(0, distance);
|
|
||||||
totalHeight += distance;
|
|
||||||
if (totalHeight >= scrollHeight) {
|
|
||||||
clearInterval(scrollDown);
|
|
||||||
window.scroll(0, 0);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.race([scrollingPromise, timeoutPromise]);
|
|
||||||
};
|
|
|
@ -0,0 +1,360 @@
|
||||||
|
import { chromium, devices } from "playwright";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import createFile from "./storage/createFile";
|
||||||
|
import sendToWayback from "./sendToWayback";
|
||||||
|
import { Readability } from "@mozilla/readability";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { Collection, Link, User } from "@prisma/client";
|
||||||
|
import validateUrlSize from "./validateUrlSize";
|
||||||
|
import removeFile from "./storage/removeFile";
|
||||||
|
import Jimp from "jimp";
|
||||||
|
import createFolder from "./storage/createFolder";
|
||||||
|
|
||||||
|
type LinksAndCollectionAndOwner = Link & {
|
||||||
|
collection: Collection & {
|
||||||
|
owner: User;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext(devices["Desktop Chrome"]);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
|
||||||
|
|
||||||
|
if (validatedUrl === null) throw "File is too large to be stored.";
|
||||||
|
|
||||||
|
const contentType = validatedUrl?.get("content-type");
|
||||||
|
let linkType = "url";
|
||||||
|
let imageExtension = "png";
|
||||||
|
|
||||||
|
if (!link.url) linkType = link.type;
|
||||||
|
else if (contentType === "application/pdf") linkType = "pdf";
|
||||||
|
else if (contentType?.startsWith("image")) {
|
||||||
|
linkType = "image";
|
||||||
|
if (contentType === "image/jpeg") imageExtension = "jpeg";
|
||||||
|
else if (contentType === "image/png") imageExtension = "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = link.collection?.owner;
|
||||||
|
|
||||||
|
// send to archive.org
|
||||||
|
if (user.archiveAsWaybackMachine && link.url) sendToWayback(link.url);
|
||||||
|
|
||||||
|
const targetLink = await prisma.link.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: {
|
||||||
|
type: linkType,
|
||||||
|
image:
|
||||||
|
user.archiveAsScreenshot && !link.image?.startsWith("archive")
|
||||||
|
? "pending"
|
||||||
|
: undefined,
|
||||||
|
pdf:
|
||||||
|
user.archiveAsPDF && !link.pdf?.startsWith("archive")
|
||||||
|
? "pending"
|
||||||
|
: undefined,
|
||||||
|
readable: !link.readable?.startsWith("archive") ? "pending" : undefined,
|
||||||
|
preview: !link.readable?.startsWith("archive") ? "pending" : undefined,
|
||||||
|
lastPreserved: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (linkType === "image" && !link.image?.startsWith("archive")) {
|
||||||
|
await imageHandler(link, imageExtension); // archive image (jpeg/png)
|
||||||
|
return;
|
||||||
|
} else if (linkType === "pdf" && !link.pdf?.startsWith("archive")) {
|
||||||
|
await pdfHandler(link); // archive pdf
|
||||||
|
return;
|
||||||
|
} else if (link.url) {
|
||||||
|
// archive url
|
||||||
|
|
||||||
|
await page.goto(link.url, { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
const content = await page.content();
|
||||||
|
|
||||||
|
// TODO single file
|
||||||
|
// const session = await page.context().newCDPSession(page);
|
||||||
|
// const doc = await session.send("Page.captureSnapshot", {
|
||||||
|
// format: "mhtml",
|
||||||
|
// });
|
||||||
|
// const saveDocLocally = (doc: any) => {
|
||||||
|
// console.log(doc);
|
||||||
|
// return createFile({
|
||||||
|
// data: doc,
|
||||||
|
// filePath: `archives/${targetLink.collectionId}/${link.id}.mhtml`,
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
// saveDocLocally(doc.data);
|
||||||
|
|
||||||
|
// Readability
|
||||||
|
const window = new JSDOM("").window;
|
||||||
|
const purify = DOMPurify(window);
|
||||||
|
const cleanedUpContent = purify.sanitize(content);
|
||||||
|
const dom = new JSDOM(cleanedUpContent, { url: link.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
|
||||||
|
if (
|
||||||
|
articleText &&
|
||||||
|
articleText !== "" &&
|
||||||
|
!link.readable?.startsWith("archive")
|
||||||
|
) {
|
||||||
|
await createFile({
|
||||||
|
data: JSON.stringify(article),
|
||||||
|
filePath: `archives/${targetLink.collectionId}/${link.id}_readability.json`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.link.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: {
|
||||||
|
readable: `archives/${targetLink.collectionId}/${link.id}_readability.json`,
|
||||||
|
textContent: articleText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
|
||||||
|
const ogImageUrl = await page.evaluate(() => {
|
||||||
|
const metaTag = document.querySelector('meta[property="og:image"]');
|
||||||
|
return metaTag ? (metaTag as any).content : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
createFolder({
|
||||||
|
filePath: `archives/preview/${link.collectionId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ogImageUrl) {
|
||||||
|
console.log("Found og:image URL:", ogImageUrl);
|
||||||
|
|
||||||
|
// Download the image
|
||||||
|
const imageResponse = await page.goto(ogImageUrl);
|
||||||
|
|
||||||
|
// Check if imageResponse is not null
|
||||||
|
if (imageResponse && !link.preview?.startsWith("archive")) {
|
||||||
|
const buffer = await imageResponse.body();
|
||||||
|
|
||||||
|
// Check if buffer is not null
|
||||||
|
if (buffer) {
|
||||||
|
// Load the image using Jimp
|
||||||
|
Jimp.read(buffer, async (err, image) => {
|
||||||
|
if (image) {
|
||||||
|
image?.resize(1280, Jimp.AUTO).quality(20);
|
||||||
|
const processedBuffer = await image?.getBufferAsync(
|
||||||
|
Jimp.MIME_JPEG
|
||||||
|
);
|
||||||
|
|
||||||
|
createFile({
|
||||||
|
data: processedBuffer,
|
||||||
|
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||||
|
}).then(() => {
|
||||||
|
return prisma.link.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: {
|
||||||
|
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("Error processing the image:", err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("No image data found.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goBack();
|
||||||
|
} else if (!link.preview?.startsWith("archive")) {
|
||||||
|
console.log("No og:image found");
|
||||||
|
page
|
||||||
|
.screenshot({ type: "jpeg", quality: 20 })
|
||||||
|
.then((screenshot) => {
|
||||||
|
return createFile({
|
||||||
|
data: screenshot,
|
||||||
|
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
return prisma.link.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: {
|
||||||
|
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screenshot/PDF
|
||||||
|
await page.evaluate(
|
||||||
|
autoScroll,
|
||||||
|
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the user hasn't deleted the link by the time we're done scrolling
|
||||||
|
const linkExists = await prisma.link.findUnique({
|
||||||
|
where: { id: link.id },
|
||||||
|
});
|
||||||
|
if (linkExists) {
|
||||||
|
const processingPromises = [];
|
||||||
|
|
||||||
|
if (user.archiveAsScreenshot && !link.image?.startsWith("archive")) {
|
||||||
|
processingPromises.push(
|
||||||
|
page.screenshot({ fullPage: true }).then((screenshot) => {
|
||||||
|
return createFile({
|
||||||
|
data: screenshot,
|
||||||
|
filePath: `archives/${linkExists.collectionId}/${link.id}.png`,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (user.archiveAsPDF && !link.pdf?.startsWith("archive")) {
|
||||||
|
processingPromises.push(
|
||||||
|
page
|
||||||
|
.pdf({
|
||||||
|
width: "1366px",
|
||||||
|
height: "1931px",
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: "15px", bottom: "15px" },
|
||||||
|
})
|
||||||
|
.then((pdf) => {
|
||||||
|
return createFile({
|
||||||
|
data: pdf,
|
||||||
|
filePath: `archives/${linkExists.collectionId}/${link.id}.pdf`,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.allSettled(processingPromises);
|
||||||
|
await prisma.link.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: {
|
||||||
|
image: user.archiveAsScreenshot
|
||||||
|
? `archives/${linkExists.collectionId}/${link.id}.png`
|
||||||
|
: undefined,
|
||||||
|
pdf: user.archiveAsPDF
|
||||||
|
? `archives/${linkExists.collectionId}/${link.id}.pdf`
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
console.log("Failed Link details:", link);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
const finalLink = await prisma.link.findUnique({
|
||||||
|
where: { id: link.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (finalLink)
|
||||||
|
await prisma.link.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: {
|
||||||
|
lastPreserved: new Date().toISOString(),
|
||||||
|
readable: !finalLink.readable?.startsWith("archives")
|
||||||
|
? "unavailable"
|
||||||
|
: undefined,
|
||||||
|
image: !finalLink.image?.startsWith("archives")
|
||||||
|
? "unavailable"
|
||||||
|
: undefined,
|
||||||
|
pdf: !finalLink.pdf?.startsWith("archives")
|
||||||
|
? "unavailable"
|
||||||
|
: undefined,
|
||||||
|
preview: !finalLink.preview?.startsWith("archives")
|
||||||
|
? "unavailable"
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
|
||||||
|
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
|
||||||
|
removeFile({
|
||||||
|
filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
|
||||||
|
});
|
||||||
|
removeFile({
|
||||||
|
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
|
||||||
|
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
reject(new Error(`Webpage was too long to be archived.`));
|
||||||
|
}, AUTOSCROLL_TIMEOUT * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollingPromise = new Promise<void>((resolve) => {
|
||||||
|
let totalHeight = 0;
|
||||||
|
let distance = 100;
|
||||||
|
let scrollDown = setInterval(() => {
|
||||||
|
let scrollHeight = document.body.scrollHeight;
|
||||||
|
window.scrollBy(0, distance);
|
||||||
|
totalHeight += distance;
|
||||||
|
if (totalHeight >= scrollHeight) {
|
||||||
|
clearInterval(scrollDown);
|
||||||
|
window.scroll(0, 0);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.race([scrollingPromise, timeoutPromise]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageHandler = async ({ url, id }: Link, extension: string) => {
|
||||||
|
const image = await fetch(url as string).then((res) => res.blob());
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await image.arrayBuffer());
|
||||||
|
|
||||||
|
const linkExists = await prisma.link.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (linkExists) {
|
||||||
|
await createFile({
|
||||||
|
data: buffer,
|
||||||
|
filePath: `archives/${linkExists.collectionId}/${id}.${extension}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.link.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
image: `archives/${linkExists.collectionId}/${id}.${extension}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pdfHandler = async ({ url, id }: Link) => {
|
||||||
|
const pdf = await fetch(url as string).then((res) => res.blob());
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await pdf.arrayBuffer());
|
||||||
|
|
||||||
|
const linkExists = await prisma.link.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (linkExists) {
|
||||||
|
await createFile({
|
||||||
|
data: buffer,
|
||||||
|
filePath: `archives/${linkExists.collectionId}/${id}.pdf`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.link.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
pdf: `archives/${linkExists.collectionId}/${id}.pdf`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
|
@ -6,8 +6,8 @@ export default async function getDashboardData(
|
||||||
query: LinkRequestQuery
|
query: LinkRequestQuery
|
||||||
) {
|
) {
|
||||||
let order: any;
|
let order: any;
|
||||||
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
|
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||||
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
|
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||||
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
||||||
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
||||||
|
@ -42,11 +42,11 @@ export default async function getDashboardData(
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: order || { createdAt: "desc" },
|
orderBy: order || { id: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentlyAddedLinks = await prisma.link.findMany({
|
const recentlyAddedLinks = await prisma.link.findMany({
|
||||||
take: 6,
|
take: 8,
|
||||||
where: {
|
where: {
|
||||||
collection: {
|
collection: {
|
||||||
OR: [
|
OR: [
|
||||||
|
@ -67,11 +67,11 @@ export default async function getDashboardData(
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: order || { createdAt: "desc" },
|
orderBy: order || { id: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
|
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
|
||||||
(a, b) => (new Date(b.createdAt) as any) - (new Date(a.createdAt) as any)
|
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
|
||||||
);
|
);
|
||||||
|
|
||||||
return { response: links, status: 200 };
|
return { response: links, status: 200 };
|
||||||
|
|
|
@ -5,8 +5,8 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
|
||||||
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
|
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
|
||||||
|
|
||||||
let order: any;
|
let order: any;
|
||||||
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
|
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||||
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
|
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||||
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
||||||
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
||||||
|
@ -145,7 +145,7 @@ export default async function getLink(userId: number, query: LinkRequestQuery) {
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: order || { createdAt: "desc" },
|
orderBy: order || { id: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { response: links, status: 200 };
|
return { response: links, status: 200 };
|
||||||
|
|
|
@ -1,17 +1,19 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import getTitle from "@/lib/api/getTitle";
|
import getTitle from "@/lib/shared/getTitle";
|
||||||
import archive from "@/lib/api/archive";
|
|
||||||
import { UsersAndCollections } from "@prisma/client";
|
import { UsersAndCollections } from "@prisma/client";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
|
import validateUrlSize from "../../validateUrlSize";
|
||||||
|
|
||||||
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
export default async function postLink(
|
export default async function postLink(
|
||||||
link: LinkIncludingShortenedCollectionAndTags,
|
link: LinkIncludingShortenedCollectionAndTags,
|
||||||
userId: number
|
userId: number
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
new URL(link.url);
|
new URL(link.url || "");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
response:
|
response:
|
||||||
|
@ -24,6 +26,20 @@ export default async function postLink(
|
||||||
link.collection.name = "Unorganized";
|
link.collection.name = "Unorganized";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (numberOfLinksTheUserHas + 1 > MAX_LINKS_PER_USER)
|
||||||
|
return {
|
||||||
|
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
link.collection.name = link.collection.name.trim();
|
link.collection.name = link.collection.name.trim();
|
||||||
|
|
||||||
if (link.collection.id) {
|
if (link.collection.id) {
|
||||||
|
@ -45,14 +61,33 @@ export default async function postLink(
|
||||||
const description =
|
const description =
|
||||||
link.description && link.description !== ""
|
link.description && link.description !== ""
|
||||||
? link.description
|
? link.description
|
||||||
: await getTitle(link.url);
|
: link.url
|
||||||
|
? await getTitle(link.url)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
|
||||||
|
|
||||||
|
if (validatedUrl === null)
|
||||||
|
return { response: "File is too large to be stored.", status: 400 };
|
||||||
|
|
||||||
|
const contentType = validatedUrl?.get("content-type");
|
||||||
|
let linkType = "url";
|
||||||
|
let imageExtension = "png";
|
||||||
|
|
||||||
|
if (!link.url) linkType = link.type;
|
||||||
|
else if (contentType === "application/pdf") linkType = "pdf";
|
||||||
|
else if (contentType?.startsWith("image")) {
|
||||||
|
linkType = "image";
|
||||||
|
if (contentType === "image/jpeg") imageExtension = "jpeg";
|
||||||
|
else if (contentType === "image/png") imageExtension = "png";
|
||||||
|
}
|
||||||
|
|
||||||
const newLink = await prisma.link.create({
|
const newLink = await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
url: link.url,
|
url: link.url,
|
||||||
name: link.name,
|
name: link.name,
|
||||||
description,
|
description,
|
||||||
readabilityPath: "pending",
|
type: linkType,
|
||||||
collection: {
|
collection: {
|
||||||
connectOrCreate: {
|
connectOrCreate: {
|
||||||
where: {
|
where: {
|
||||||
|
@ -91,7 +126,5 @@ export default async function postLink(
|
||||||
|
|
||||||
createFolder({ filePath: `archives/${newLink.collectionId}` });
|
createFolder({ filePath: `archives/${newLink.collectionId}` });
|
||||||
|
|
||||||
archive(newLink.id, newLink.url, userId);
|
|
||||||
|
|
||||||
return { response: newLink, status: 200 };
|
return { response: newLink, status: 200 };
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { Backup } from "@/types/global";
|
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
export default async function importFromHTMLFile(
|
export default async function importFromHTMLFile(
|
||||||
userId: number,
|
userId: number,
|
||||||
rawData: string
|
rawData: string
|
||||||
|
@ -10,6 +11,23 @@ export default async function importFromHTMLFile(
|
||||||
const dom = new JSDOM(rawData);
|
const dom = new JSDOM(rawData);
|
||||||
const document = dom.window.document;
|
const document = dom.window.document;
|
||||||
|
|
||||||
|
const bookmarks = document.querySelectorAll("A");
|
||||||
|
const totalImports = bookmarks.length;
|
||||||
|
|
||||||
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||||
|
return {
|
||||||
|
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
const folders = document.querySelectorAll("H3");
|
const folders = document.querySelectorAll("H3");
|
||||||
|
|
||||||
await prisma
|
await prisma
|
||||||
|
|
|
@ -2,9 +2,34 @@ import { prisma } from "@/lib/api/db";
|
||||||
import { Backup } from "@/types/global";
|
import { Backup } from "@/types/global";
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
|
|
||||||
export default async function getData(userId: number, rawData: string) {
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
|
export default async function importFromLinkwarden(
|
||||||
|
userId: number,
|
||||||
|
rawData: string
|
||||||
|
) {
|
||||||
const data: Backup = JSON.parse(rawData);
|
const data: Backup = JSON.parse(rawData);
|
||||||
|
|
||||||
|
let totalImports = 0;
|
||||||
|
|
||||||
|
data.collections.forEach((collection) => {
|
||||||
|
totalImports += collection.links.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const numberOfLinksTheUserHas = await prisma.link.count({
|
||||||
|
where: {
|
||||||
|
collection: {
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
|
||||||
|
return {
|
||||||
|
response: `Error: Each user can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
await prisma
|
await prisma
|
||||||
.$transaction(
|
.$transaction(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
|
@ -7,8 +7,8 @@ export default async function getLink(
|
||||||
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
|
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
|
||||||
|
|
||||||
let order: any;
|
let order: any;
|
||||||
if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" };
|
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
|
||||||
else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" };
|
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
|
||||||
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
|
||||||
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
else if (query.sort === Sort.NameZA) order = { name: "desc" };
|
||||||
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" };
|
||||||
|
@ -81,7 +81,7 @@ export default async function getLink(
|
||||||
include: {
|
include: {
|
||||||
tags: true,
|
tags: true,
|
||||||
},
|
},
|
||||||
orderBy: order || { createdAt: "desc" },
|
orderBy: order || { id: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
return { response: links, status: 200 };
|
return { response: links, status: 200 };
|
||||||
|
|
|
@ -74,6 +74,8 @@ export default async function getPublicUser(
|
||||||
name: lessSensitiveInfo.name,
|
name: lessSensitiveInfo.name,
|
||||||
username: lessSensitiveInfo.username,
|
username: lessSensitiveInfo.username,
|
||||||
image: lessSensitiveInfo.image,
|
image: lessSensitiveInfo.image,
|
||||||
|
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
|
||||||
|
archiveAsPDF: lessSensitiveInfo.archiveAsPDF,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { response: data, status: 200 };
|
return { response: data, status: 200 };
|
||||||
|
|
|
@ -81,6 +81,12 @@ export default async function deleteUserById(
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.usersAndCollections.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ userId: userId }, { collection: { ownerId: userId } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Delete user's avatar
|
// Delete user's avatar
|
||||||
await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
await removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||||
|
|
||||||
|
|
|
@ -13,106 +13,154 @@ export default async function updateUserById(
|
||||||
userId: number,
|
userId: number,
|
||||||
data: AccountSettings
|
data: AccountSettings
|
||||||
) {
|
) {
|
||||||
if (emailEnabled && !data.email)
|
const ssoUser = await prisma.account.findFirst({
|
||||||
return {
|
|
||||||
response: "Email invalid.",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
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(data.username.toLowerCase()))
|
|
||||||
return {
|
|
||||||
response:
|
|
||||||
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
const userIsTaken = await prisma.user.findFirst({
|
|
||||||
where: {
|
where: {
|
||||||
id: { not: userId },
|
userId: userId,
|
||||||
OR: emailEnabled
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
username: data.username.toLowerCase(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
email: data.email?.toLowerCase(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
username: data.username.toLowerCase(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
if (userIsTaken) {
|
where: {
|
||||||
if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim())
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (ssoUser) {
|
||||||
|
// deny changes to SSO-defined properties
|
||||||
|
if (data.email !== user?.email) {
|
||||||
return {
|
return {
|
||||||
response: "Email is taken.",
|
response: "SSO users cannot change their email.",
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
else if (
|
|
||||||
data.username?.toLowerCase().trim() === userIsTaken.username?.trim()
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
response: "Username is taken.",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: "Username/Email is taken.",
|
|
||||||
status: 400,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avatar Settings
|
|
||||||
|
|
||||||
if (data.image?.startsWith("data:image/jpeg;base64")) {
|
|
||||||
if (data.image.length < 1572864) {
|
|
||||||
try {
|
|
||||||
const base64Data = data.image.replace(/^data:image\/jpeg;base64,/, "");
|
|
||||||
|
|
||||||
createFolder({ filePath: `uploads/avatar` });
|
|
||||||
|
|
||||||
await createFile({
|
|
||||||
filePath: `uploads/avatar/${userId}.jpg`,
|
|
||||||
data: base64Data,
|
|
||||||
isBase64: true,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.log("Error saving image:", err);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("A file larger than 1.5MB was uploaded.");
|
|
||||||
return {
|
|
||||||
response: "A file larger than 1.5MB was uploaded.",
|
|
||||||
status: 400,
|
status: 400,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (data.image == "") {
|
if (data.newPassword) {
|
||||||
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
return {
|
||||||
|
response: "SSO Users cannot change their password.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.name !== user?.name) {
|
||||||
|
return {
|
||||||
|
response: "SSO Users cannot change their name.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.username !== user?.username) {
|
||||||
|
return {
|
||||||
|
response: "SSO Users cannot change their username.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (data.image !== "") {
|
||||||
|
return {
|
||||||
|
response: "SSO Users cannot change their avatar.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// verify only for non-SSO users
|
||||||
|
// SSO users cannot change their email, password, name, username, or avatar
|
||||||
|
if (emailEnabled && !data.email)
|
||||||
|
return {
|
||||||
|
response: "Email invalid.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
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(data.username.toLowerCase()))
|
||||||
|
return {
|
||||||
|
response:
|
||||||
|
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
const userIsTaken = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: { not: userId },
|
||||||
|
OR: emailEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
username: data.username.toLowerCase(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: data.email?.toLowerCase(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
username: data.username.toLowerCase(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userIsTaken) {
|
||||||
|
if (data.email?.toLowerCase().trim() === userIsTaken.email?.trim())
|
||||||
|
return {
|
||||||
|
response: "Email is taken.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
else if (
|
||||||
|
data.username?.toLowerCase().trim() === userIsTaken.username?.trim()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
response: "Username is taken.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: "Username/Email is taken.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatar Settings
|
||||||
|
|
||||||
|
if (data.image?.startsWith("data:image/jpeg;base64")) {
|
||||||
|
if (data.image.length < 1572864) {
|
||||||
|
try {
|
||||||
|
const base64Data = data.image.replace(
|
||||||
|
/^data:image\/jpeg;base64,/,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
createFolder({ filePath: `uploads/avatar` });
|
||||||
|
|
||||||
|
await createFile({
|
||||||
|
filePath: `uploads/avatar/${userId}.jpg`,
|
||||||
|
data: base64Data,
|
||||||
|
isBase64: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Error saving image:", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("A file larger than 1.5MB was uploaded.");
|
||||||
|
return {
|
||||||
|
response: "A file larger than 1.5MB was uploaded.",
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (data.image == "") {
|
||||||
|
removeFile({ filePath: `uploads/avatar/${userId}.jpg` });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousEmail = (
|
const previousEmail = (
|
||||||
|
@ -130,15 +178,13 @@ export default async function updateUserById(
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
username: data.username.toLowerCase().trim(),
|
username: data.username?.toLowerCase().trim(),
|
||||||
email: data.email?.toLowerCase().trim(),
|
email: data.email?.toLowerCase().trim(),
|
||||||
isPrivate: data.isPrivate,
|
isPrivate: data.isPrivate,
|
||||||
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
|
image: data.image ? `uploads/avatar/${userId}.jpg` : "",
|
||||||
archiveAsScreenshot: data.archiveAsScreenshot,
|
archiveAsScreenshot: data.archiveAsScreenshot,
|
||||||
archiveAsPDF: data.archiveAsPDF,
|
archiveAsPDF: data.archiveAsPDF,
|
||||||
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
|
archiveAsWaybackMachine: data.archiveAsWaybackMachine,
|
||||||
displayLinkIcons: data.displayLinkIcons,
|
|
||||||
blurredFavicons: data.blurredFavicons,
|
|
||||||
password:
|
password:
|
||||||
data.newPassword && data.newPassword !== ""
|
data.newPassword && data.newPassword !== ""
|
||||||
? newHashedPassword
|
? newHashedPassword
|
||||||
|
|
|
@ -26,7 +26,7 @@ function html(params: { url: string; host: string; theme: Theme }) {
|
||||||
|
|
||||||
const escapedHost = host.replace(/\./g, "​.");
|
const escapedHost = host.replace(/\./g, "​.");
|
||||||
|
|
||||||
const brandColor = theme.brandColor || "#346df1";
|
const brandColor = theme.brandColor || "#0029cf";
|
||||||
const color = {
|
const color = {
|
||||||
background: "#f9f9f9",
|
background: "#f9f9f9",
|
||||||
text: "#444",
|
text: "#444",
|
||||||
|
@ -50,10 +50,11 @@ function html(params: { url: string; host: string; theme: Theme }) {
|
||||||
<td align="center" style="padding: 20px 0;">
|
<td align="center" style="padding: 20px 0;">
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
|
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}">
|
||||||
target="_blank"
|
<a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">
|
||||||
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Sign
|
Sign in
|
||||||
in</a></td>
|
</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -97,7 +97,7 @@ export default async function readFile(filePath: string) {
|
||||||
return {
|
return {
|
||||||
file: "File not found.",
|
file: "File not found.",
|
||||||
contentType: "text/plain",
|
contentType: "text/plain",
|
||||||
status: 400,
|
status: 404,
|
||||||
};
|
};
|
||||||
else {
|
else {
|
||||||
const file = fs.readFileSync(creationPath);
|
const file = fs.readFileSync(creationPath);
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
export default async function validateUrlSize(url: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: "HEAD" });
|
||||||
|
|
||||||
|
const totalSizeMB =
|
||||||
|
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
||||||
|
if (totalSizeMB > (Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE) || 30))
|
||||||
|
return null;
|
||||||
|
else return response.headers;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,13 +31,18 @@ export default async function verifyUser({
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const ssoUser = await prisma.account.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(404).json({ response: "User not found." });
|
res.status(404).json({ response: "User not found." });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.username) {
|
if (!user.username && !ssoUser) { // SSO users don't need a username
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
response: "Username not found.",
|
response: "Username not found.",
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export default function htmlDecode(input: string) {
|
export default function unescapeString(input: string) {
|
||||||
var doc = new DOMParser().parseFromString(input, "text/html");
|
var doc = new DOMParser().parseFromString(input, "text/html");
|
||||||
return doc.documentElement.textContent;
|
return doc.documentElement.textContent;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
export function screenshotAvailable(link: any) {
|
||||||
|
return (
|
||||||
|
link &&
|
||||||
|
link.image &&
|
||||||
|
link.image !== "pending" &&
|
||||||
|
link.image !== "unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pdfAvailable(link: any) {
|
||||||
|
return (
|
||||||
|
link && link.pdf && link.pdf !== "pending" && link.pdf !== "unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readabilityAvailable(link: any) {
|
||||||
|
return (
|
||||||
|
link &&
|
||||||
|
link.readable &&
|
||||||
|
link.readable !== "pending" &&
|
||||||
|
link.readable !== "unavailable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewAvailable(link: any) {
|
||||||
|
return (
|
||||||
|
link &&
|
||||||
|
link.preview &&
|
||||||
|
link.preview !== "pending" &&
|
||||||
|
link.preview !== "unavailable"
|
||||||
|
);
|
||||||
|
}
|
34
package.json
34
package.json
|
@ -1,49 +1,53 @@
|
||||||
{
|
{
|
||||||
"name": "linkwarden",
|
"name": "linkwarden",
|
||||||
"version": "2.1.0",
|
"version": "2.4.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"repository": "https://github.com/Daniel31x13/link-warden.git",
|
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"prisma": {
|
||||||
|
"seed": "node ./prisma/seed.js"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "concurrently -k \"next dev\" \"yarn worker:dev\"",
|
||||||
|
"worker:dev": "nodemon --skip-project scripts/worker.ts",
|
||||||
|
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
||||||
|
"start": "concurrently \"next start\" \"yarn worker:prod\"",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.0.1",
|
"@auth/prisma-adapter": "^1.0.1",
|
||||||
"@aws-sdk/client-s3": "^3.379.1",
|
"@aws-sdk/client-s3": "^3.379.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
|
||||||
"@headlessui/react": "^1.7.15",
|
"@headlessui/react": "^1.7.15",
|
||||||
"@mozilla/readability": "^0.4.4",
|
"@mozilla/readability": "^0.4.4",
|
||||||
"@prisma/client": "^4.16.2",
|
"@prisma/client": "^4.16.2",
|
||||||
"@stripe/stripe-js": "^1.54.1",
|
"@stripe/stripe-js": "^1.54.1",
|
||||||
"@types/crypto-js": "^4.1.1",
|
"@types/crypto-js": "^4.1.1",
|
||||||
"@types/node": "20.4.4",
|
"@types/formidable": "^3.4.5",
|
||||||
|
"@types/node": "^20.10.4",
|
||||||
"@types/nodemailer": "^6.4.8",
|
"@types/nodemailer": "^6.4.8",
|
||||||
"@types/react": "18.2.14",
|
"@types/react": "18.2.14",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
|
"bootstrap-icons": "^1.11.2",
|
||||||
"colorthief": "^2.4.0",
|
"colorthief": "^2.4.0",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"csstype": "^3.1.2",
|
"csstype": "^3.1.2",
|
||||||
"dompurify": "^3.0.6",
|
"dompurify": "^3.0.6",
|
||||||
"eslint": "8.46.0",
|
"eslint": "8.46.0",
|
||||||
"eslint-config-next": "13.4.9",
|
"eslint-config-next": "13.4.9",
|
||||||
|
"formidable": "^3.5.1",
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^10.16.4",
|
||||||
|
"jimp": "^0.22.10",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
"lottie-web": "^5.12.2",
|
"lottie-web": "^5.12.2",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "13.4.12",
|
"next": "13.4.12",
|
||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.1",
|
||||||
"next-themes": "^0.2.1",
|
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.3",
|
||||||
"playwright": "^1.35.1",
|
"playwright": "^1.35.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
@ -52,9 +56,7 @@
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-image-file-resizer": "^0.4.8",
|
"react-image-file-resizer": "^0.4.8",
|
||||||
"react-select": "^5.7.4",
|
"react-select": "^5.7.4",
|
||||||
"sharp": "^0.32.1",
|
|
||||||
"stripe": "^12.13.0",
|
"stripe": "^12.13.0",
|
||||||
"typescript": "4.9.4",
|
|
||||||
"zustand": "^4.3.8"
|
"zustand": "^4.3.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -62,10 +64,14 @@
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/dompurify": "^3.0.4",
|
"@types/dompurify": "^3.0.4",
|
||||||
"@types/jsdom": "^21.1.3",
|
"@types/jsdom": "^21.1.3",
|
||||||
|
"@types/shelljs": "^0.8.15",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"daisyui": "^4.4.2",
|
"daisyui": "^4.4.2",
|
||||||
|
"nodemon": "^3.0.2",
|
||||||
"postcss": "^8.4.26",
|
"postcss": "^8.4.26",
|
||||||
"prisma": "^5.1.0",
|
"prisma": "^5.1.0",
|
||||||
"tailwindcss": "^3.3.3"
|
"tailwindcss": "^3.3.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "4.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import AuthRedirect from "@/layouts/AuthRedirect";
|
import AuthRedirect from "@/layouts/AuthRedirect";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import { Session } from "next-auth";
|
import { Session } from "next-auth";
|
||||||
import { ThemeProvider } from "next-themes";
|
|
||||||
|
|
||||||
export default function App({
|
export default function App({
|
||||||
Component,
|
Component,
|
||||||
|
@ -14,13 +14,6 @@ export default function App({
|
||||||
}: AppProps<{
|
}: AppProps<{
|
||||||
session: Session;
|
session: Session;
|
||||||
}>) {
|
}>) {
|
||||||
const defaultTheme: "light" | "dark" = "dark";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!localStorage.getItem("theme"))
|
|
||||||
localStorage.setItem("theme", defaultTheme);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionProvider
|
<SessionProvider
|
||||||
session={pageProps.session}
|
session={pageProps.session}
|
||||||
|
@ -50,17 +43,15 @@ export default function App({
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</Head>
|
</Head>
|
||||||
<AuthRedirect>
|
<AuthRedirect>
|
||||||
<ThemeProvider attribute="class">
|
<Toaster
|
||||||
<Toaster
|
position="top-center"
|
||||||
position="top-center"
|
reverseOrder={false}
|
||||||
reverseOrder={false}
|
toastOptions={{
|
||||||
toastOptions={{
|
className:
|
||||||
className:
|
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
||||||
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
<Component {...pageProps} />
|
||||||
<Component {...pageProps} />
|
|
||||||
</ThemeProvider>
|
|
||||||
</AuthRedirect>
|
</AuthRedirect>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,47 +3,153 @@ import readFile from "@/lib/api/storage/readFile";
|
||||||
import { getToken } from "next-auth/jwt";
|
import { getToken } from "next-auth/jwt";
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import { ArchivedFormat } from "@/types/global";
|
import { ArchivedFormat } from "@/types/global";
|
||||||
|
import verifyUser from "@/lib/api/verifyUser";
|
||||||
|
import getPermission from "@/lib/api/getPermission";
|
||||||
|
import { UsersAndCollections } from "@prisma/client";
|
||||||
|
import formidable from "formidable";
|
||||||
|
import createFile from "@/lib/api/storage/createFile";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const linkId = Number(req.query.linkId);
|
const linkId = Number(req.query.linkId);
|
||||||
const format = Number(req.query.format);
|
const format = Number(req.query.format);
|
||||||
|
const isPreview = Boolean(req.query.preview);
|
||||||
|
|
||||||
let suffix;
|
let suffix: string;
|
||||||
|
|
||||||
if (format === ArchivedFormat.screenshot) suffix = ".png";
|
if (format === ArchivedFormat.png) suffix = ".png";
|
||||||
|
else if (format === ArchivedFormat.jpeg) suffix = ".jpeg";
|
||||||
else if (format === ArchivedFormat.pdf) suffix = ".pdf";
|
else if (format === ArchivedFormat.pdf) suffix = ".pdf";
|
||||||
else if (format === ArchivedFormat.readability) suffix = "_readability.json";
|
else if (format === ArchivedFormat.readability) suffix = "_readability.json";
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
if (!linkId || !suffix)
|
if (!linkId || !suffix)
|
||||||
return res.status(401).json({ response: "Invalid parameters." });
|
return res.status(401).json({ response: "Invalid parameters." });
|
||||||
|
|
||||||
const token = await getToken({ req });
|
if (req.method === "GET") {
|
||||||
const userId = token?.id;
|
const token = await getToken({ req });
|
||||||
|
const userId = token?.id;
|
||||||
|
|
||||||
const collectionIsAccessible = await prisma.collection.findFirst({
|
const collectionIsAccessible = await prisma.collection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
links: {
|
links: {
|
||||||
some: {
|
some: {
|
||||||
id: linkId,
|
id: linkId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
OR: [
|
||||||
|
{ ownerId: userId || -1 },
|
||||||
|
{ members: { some: { userId: userId || -1 } } },
|
||||||
|
{ isPublic: true },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
OR: [
|
});
|
||||||
{ ownerId: userId || -1 },
|
|
||||||
{ members: { some: { userId: userId || -1 } } },
|
|
||||||
{ isPublic: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!collectionIsAccessible)
|
if (!collectionIsAccessible)
|
||||||
return res
|
return res
|
||||||
.status(401)
|
.status(401)
|
||||||
.json({ response: "You don't have access to this collection." });
|
.json({ response: "You don't have access to this collection." });
|
||||||
|
|
||||||
const { file, contentType, status } = await readFile(
|
if (isPreview) {
|
||||||
`archives/${collectionIsAccessible.id}/${linkId + suffix}`
|
const { file, contentType, status } = await readFile(
|
||||||
);
|
`archives/preview/${collectionIsAccessible.id}/${linkId}.jpeg`
|
||||||
res.setHeader("Content-Type", contentType).status(status as number);
|
);
|
||||||
|
|
||||||
return res.send(file);
|
res.setHeader("Content-Type", contentType).status(status as number);
|
||||||
|
|
||||||
|
return res.send(file);
|
||||||
|
} else {
|
||||||
|
const { file, contentType, status } = await readFile(
|
||||||
|
`archives/${collectionIsAccessible.id}/${linkId + suffix}`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", contentType).status(status as number);
|
||||||
|
|
||||||
|
return res.send(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else if (req.method === "POST") {
|
||||||
|
// const user = await verifyUser({ req, res });
|
||||||
|
// if (!user) return;
|
||||||
|
|
||||||
|
// const collectionPermissions = await getPermission({
|
||||||
|
// userId: user.id,
|
||||||
|
// linkId,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const memberHasAccess = collectionPermissions?.members.some(
|
||||||
|
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
|
||||||
|
// return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
|
// // await uploadHandler(linkId, )
|
||||||
|
|
||||||
|
// const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
|
||||||
|
|
||||||
|
// const form = formidable({
|
||||||
|
// maxFields: 1,
|
||||||
|
// maxFiles: 1,
|
||||||
|
// maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// form.parse(req, async (err, fields, files) => {
|
||||||
|
// const allowedMIMETypes = [
|
||||||
|
// "application/pdf",
|
||||||
|
// "image/png",
|
||||||
|
// "image/jpg",
|
||||||
|
// "image/jpeg",
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// if (
|
||||||
|
// err ||
|
||||||
|
// !files.file ||
|
||||||
|
// !files.file[0] ||
|
||||||
|
// !allowedMIMETypes.includes(files.file[0].mimetype || "")
|
||||||
|
// ) {
|
||||||
|
// // Handle parsing error
|
||||||
|
// return res.status(500).json({
|
||||||
|
// response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// const fileBuffer = fs.readFileSync(files.file[0].filepath);
|
||||||
|
|
||||||
|
// const linkStillExists = await prisma.link.findUnique({
|
||||||
|
// where: { id: linkId },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (linkStillExists) {
|
||||||
|
// await createFile({
|
||||||
|
// filePath: `archives/${collectionPermissions?.id}/${
|
||||||
|
// linkId + suffix
|
||||||
|
// }`,
|
||||||
|
// data: fileBuffer,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// await prisma.link.update({
|
||||||
|
// where: { id: linkId },
|
||||||
|
// data: {
|
||||||
|
// image: `archives/${collectionPermissions?.id}/${
|
||||||
|
// linkId + suffix
|
||||||
|
// }`,
|
||||||
|
// lastPreserved: new Date().toISOString(),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// fs.unlinkSync(files.file[0].filepath);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return res.status(200).json({
|
||||||
|
// response: files,
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +1,9 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import archive from "@/lib/api/archive";
|
|
||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import verifyUser from "@/lib/api/verifyUser";
|
import verifyUser from "@/lib/api/verifyUser";
|
||||||
|
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||||
|
import removeFile from "@/lib/api/storage/removeFile";
|
||||||
|
import { Collection, Link } from "@prisma/client";
|
||||||
|
|
||||||
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
|
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
|
||||||
|
|
||||||
|
@ -13,7 +15,7 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||||
where: {
|
where: {
|
||||||
id: Number(req.query.id),
|
id: Number(req.query.id),
|
||||||
},
|
},
|
||||||
include: { collection: true },
|
include: { collection: { include: { owner: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!link)
|
if (!link)
|
||||||
|
@ -41,14 +43,17 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
|
||||||
} minutes or create a new one.`,
|
} minutes or create a new one.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
archive(link.id, link.url, user.id);
|
if (!link.url || !isValidUrl(link.url))
|
||||||
|
return res.status(200).json({
|
||||||
|
response: "Invalid URL.",
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteArchivedFiles(link);
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
response: "Link is being archived.",
|
response: "Link is being archived.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - Later?
|
|
||||||
// else if (req.method === "DELETE") {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
|
const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
|
||||||
|
@ -61,3 +66,30 @@ const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
|
||||||
|
|
||||||
return diffInMinutes;
|
return diffInMinutes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
|
||||||
|
await prisma.link.update({
|
||||||
|
where: {
|
||||||
|
id: link.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
image: null,
|
||||||
|
pdf: null,
|
||||||
|
readable: null,
|
||||||
|
preview: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await removeFile({
|
||||||
|
filePath: `archives/${link.collection.id}/${link.id}.pdf`,
|
||||||
|
});
|
||||||
|
await removeFile({
|
||||||
|
filePath: `archives/${link.collection.id}/${link.id}.png`,
|
||||||
|
});
|
||||||
|
await removeFile({
|
||||||
|
filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
|
||||||
|
});
|
||||||
|
await removeFile({
|
||||||
|
filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,238 @@
|
||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import * as process from "process";
|
||||||
|
|
||||||
|
export type ResponseData = {
|
||||||
|
credentialsEnabled: string|undefined
|
||||||
|
emailEnabled: string|undefined
|
||||||
|
registrationDisabled: string|undefined
|
||||||
|
buttonAuths: {
|
||||||
|
method: string
|
||||||
|
name: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
export default function handler(req: NextApiRequest, res: NextApiResponse<ResponseData>) {
|
||||||
|
res.json(getLogins());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogins() {
|
||||||
|
const buttonAuths = []
|
||||||
|
|
||||||
|
// 42 School
|
||||||
|
if (process.env.NEXT_PUBLIC_FORTYTWO_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: '42-school', name: process.env.FORTYTWO_CUSTOM_NAME ?? '42 School'});
|
||||||
|
}
|
||||||
|
// Apple
|
||||||
|
if (process.env.NEXT_PUBLIC_APPLE_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'apple', name: process.env.APPLE_CUSTOM_NAME ?? 'Apple'});
|
||||||
|
}
|
||||||
|
// Atlassian
|
||||||
|
if (process.env.NEXT_PUBLIC_ATLASSIAN_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'atlassian', name: process.env.ATLASSIAN_CUSTOM_NAME ?? 'Atlassian'});
|
||||||
|
}
|
||||||
|
// Auth0
|
||||||
|
if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'auth0', name: process.env.AUTH0_CUSTOM_NAME ?? 'Auth0'});
|
||||||
|
}
|
||||||
|
// Authentik
|
||||||
|
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'authentik', name: process.env.AUTHENTIK_CUSTOM_NAME ?? 'Authentik'});
|
||||||
|
}
|
||||||
|
// Battle.net
|
||||||
|
if (process.env.NEXT_PUBLIC_BATTLENET_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'battlenet', name: process.env.BATTLENET_CUSTOM_NAME ?? 'Battle.net'});
|
||||||
|
}
|
||||||
|
// Box
|
||||||
|
if (process.env.NEXT_PUBLIC_BOX_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'box', name: process.env.BOX_CUSTOM_NAME ?? 'Box'});
|
||||||
|
}
|
||||||
|
// Cognito
|
||||||
|
if (process.env.NEXT_PUBLIC_COGNITO_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'cognito', name: process.env.COGNITO_CUSTOM_NAME ?? 'Cognito'});
|
||||||
|
}
|
||||||
|
// Coinbase
|
||||||
|
if (process.env.NEXT_PUBLIC_COINBASE_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'coinbase', name: process.env.COINBASE_CUSTOM_NAME ?? 'Coinbase'});
|
||||||
|
}
|
||||||
|
// Discord
|
||||||
|
if (process.env.NEXT_PUBLIC_DISCORD_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'discord', name: process.env.DISCORD_CUSTOM_NAME ?? 'Discord'});
|
||||||
|
}
|
||||||
|
// Dropbox
|
||||||
|
if (process.env.NEXT_PUBLIC_DROPBOX_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'dropbox', name: process.env.DROPBOX_CUSTOM_NAME ?? 'Dropbox'});
|
||||||
|
}
|
||||||
|
// Duende IdentityServer6
|
||||||
|
if (process.env.NEXT_PUBLIC_DUENDE_IDS6_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'duende-identityserver6', name: process.env.DUENDE_IDS6_CUSTOM_NAME ?? 'DuendeIdentityServer6'});
|
||||||
|
}
|
||||||
|
// EVE Online
|
||||||
|
if (process.env.NEXT_PUBLIC_EVEONLINE_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'eveonline', name: process.env.EVEONLINE_CUSTOM_NAME ?? 'EVE Online'});
|
||||||
|
}
|
||||||
|
// Facebook
|
||||||
|
if (process.env.NEXT_PUBLIC_FACEBOOK_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'facebook', name: process.env.FACEBOOK_CUSTOM_NAME ?? 'Facebook'});
|
||||||
|
}
|
||||||
|
// FACEIT
|
||||||
|
if (process.env.NEXT_PUBLIC_FACEIT_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'faceit', name: process.env.FACEIT_CUSTOM_NAME ?? 'FACEIT'});
|
||||||
|
}
|
||||||
|
// Foursquare
|
||||||
|
if (process.env.NEXT_PUBLIC_FOURSQUARE_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'foursquare', name: process.env.FOURSQUARE_CUSTOM_NAME ?? 'Foursquare'});
|
||||||
|
}
|
||||||
|
// Freshbooks
|
||||||
|
if (process.env.NEXT_PUBLIC_FRESHBOOKS_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'freshbooks', name: process.env.FRESHBOOKS_CUSTOM_NAME ?? 'Freshbooks'});
|
||||||
|
}
|
||||||
|
// FusionAuth
|
||||||
|
if (process.env.NEXT_PUBLIC_FUSIONAUTH_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'fusionauth', name: process.env.FUSIONAUTH_CUSTOM_NAME ?? 'FusionAuth'});
|
||||||
|
}
|
||||||
|
// GitHub
|
||||||
|
if (process.env.NEXT_PUBLIC_GITHUB_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'github', name: process.env.GITHUB_CUSTOM_NAME ?? 'GitHub'});
|
||||||
|
}
|
||||||
|
// GitLab
|
||||||
|
if (process.env.NEXT_PUBLIC_GITLAB_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'gitlab', name: process.env.GITLAB_CUSTOM_NAME ?? 'GitLab'});
|
||||||
|
}
|
||||||
|
// Google
|
||||||
|
if (process.env.NEXT_PUBLIC_GOOGLE_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'google', name: process.env.GOOGLE_CUSTOM_NAME ?? 'Google'});
|
||||||
|
}
|
||||||
|
// HubSpot
|
||||||
|
if (process.env.NEXT_PUBLIC_HUBSPOT_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'hubspot', name: process.env.HUBSPOT_CUSTOM_NAME ?? 'HubSpot'});
|
||||||
|
}
|
||||||
|
// IdentityServer4
|
||||||
|
if (process.env.NEXT_PUBLIC_IDS4_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'identity-server4', name: process.env.IDS4_CUSTOM_NAME ?? 'IdentityServer4'});
|
||||||
|
}
|
||||||
|
// Kakao
|
||||||
|
if (process.env.NEXT_PUBLIC_KAKAO_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'kakao', name: process.env.KAKAO_CUSTOM_NAME ?? 'Kakao'});
|
||||||
|
}
|
||||||
|
// Keycloak
|
||||||
|
if (process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'keycloak', name: process.env.KEYCLOAK_CUSTOM_NAME ?? 'Keycloak'});
|
||||||
|
}
|
||||||
|
// LINE
|
||||||
|
if (process.env.NEXT_PUBLIC_LINE_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'line', name: process.env.LINE_CUSTOM_NAME ?? 'LINE'});
|
||||||
|
}
|
||||||
|
// LinkedIn
|
||||||
|
if (process.env.NEXT_PUBLIC_LINKEDIN_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'linkedin', name: process.env.LINKEDIN_CUSTOM_NAME ?? 'LinkedIn'});
|
||||||
|
}
|
||||||
|
// MailChimp
|
||||||
|
if (process.env.NEXT_PUBLIC_MAILCHIMP_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'mailchimp', name: process.env.MAILCHIMP_CUSTOM_NAME ?? 'Mailchimp'});
|
||||||
|
}
|
||||||
|
// Mail.ru
|
||||||
|
if (process.env.NEXT_PUBLIC_MAILRU_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'mailru', name: process.env.MAILRU_CUSTOM_NAME ?? 'Mail.ru'});
|
||||||
|
}
|
||||||
|
// Naver
|
||||||
|
if (process.env.NEXT_PUBLIC_NAVER_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'naver', name: process.env.NAVER_CUSTOM_NAME ?? 'Naver'});
|
||||||
|
}
|
||||||
|
// Netlify
|
||||||
|
if (process.env.NEXT_PUBLIC_NETLIFY_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'netlify', name: process.env.NETLIFY_CUSTOM_NAME ?? 'Netlify'});
|
||||||
|
}
|
||||||
|
// Okta
|
||||||
|
if (process.env.NEXT_PUBLIC_OKTA_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'okta', name: process.env.OKTA_CUSTOM_NAME ?? 'Okta'});
|
||||||
|
}
|
||||||
|
// OneLogin
|
||||||
|
if (process.env.NEXT_PUBLIC_ONELOGIN_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'onelogin', name: process.env.ONELOGIN_CUSTOM_NAME ?? 'OneLogin'});
|
||||||
|
}
|
||||||
|
// Osso
|
||||||
|
if (process.env.NEXT_PUBLIC_OSSO_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'osso', name: process.env.OSSO_CUSTOM_NAME ?? 'Osso'});
|
||||||
|
}
|
||||||
|
// osu!
|
||||||
|
if (process.env.NEXT_PUBLIC_OSU_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'osu', name: process.env.OSU_CUSTOM_NAME ?? 'Osu!'});
|
||||||
|
}
|
||||||
|
// Patreon
|
||||||
|
if (process.env.NEXT_PUBLIC_PATREON_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'patreon', name: process.env.PATREON_CUSTOM_NAME ?? 'Patreon'});
|
||||||
|
}
|
||||||
|
// Pinterest
|
||||||
|
if (process.env.NEXT_PUBLIC_PINTEREST_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'pinterest', name: process.env.PINTEREST_CUSTOM_NAME ?? 'Pinterest'});
|
||||||
|
}
|
||||||
|
// Pipedrive
|
||||||
|
if (process.env.NEXT_PUBLIC_PIPEDRIVE_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'pipedrive', name: process.env.PIPEDRIVE_CUSTOM_NAME ?? 'Pipedrive'});
|
||||||
|
}
|
||||||
|
// Reddit
|
||||||
|
if (process.env.NEXT_PUBLIC_REDDIT_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'reddit', name: process.env.REDDIT_CUSTOM_NAME ?? 'Reddit'});
|
||||||
|
}
|
||||||
|
// Salesforce
|
||||||
|
if (process.env.NEXT_PUBLIC_SALESFORCE_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'salesforce', name: process.env.SALESFORCE_CUSTOM_NAME ?? 'Salesforce'});
|
||||||
|
}
|
||||||
|
// Slack
|
||||||
|
if (process.env.NEXT_PUBLIC_SLACK_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'slack', name: process.env.SLACK_CUSTOM_NAME ?? 'Slack'});
|
||||||
|
}
|
||||||
|
// Spotify
|
||||||
|
if (process.env.NEXT_PUBLIC_SPOTIFY_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'spotify', name: process.env.SPOTIFY_CUSTOM_NAME ?? 'Spotify'});
|
||||||
|
}
|
||||||
|
// Strava
|
||||||
|
if (process.env.NEXT_PUBLIC_STRAVA_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'strava', name: process.env.STRAVA_CUSTOM_NAME ?? 'Strava'});
|
||||||
|
}
|
||||||
|
// Todoist
|
||||||
|
if (process.env.NEXT_PUBLIC_TODOIST_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'todoist', name: process.env.TODOIST_CUSTOM_NAME ?? 'Todoist'});
|
||||||
|
}
|
||||||
|
// Twitch
|
||||||
|
if (process.env.NEXT_PUBLIC_TWITCH_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'twitch', name: process.env.TWITCH_CUSTOM_NAME ?? 'Twitch'});
|
||||||
|
}
|
||||||
|
// United Effects
|
||||||
|
if (process.env.NEXT_PUBLIC_UNITED_EFFECTS_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'united-effects', name: process.env.UNITED_EFFECTS_CUSTOM_NAME ?? 'United Effects'});
|
||||||
|
}
|
||||||
|
// VK
|
||||||
|
if (process.env.NEXT_PUBLIC_VK_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'vk', name: process.env.VK_CUSTOM_NAME ?? 'VK'});
|
||||||
|
}
|
||||||
|
// Wikimedia
|
||||||
|
if (process.env.NEXT_PUBLIC_WIKIMEDIA_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'wikimedia', name: process.env.WIKIMEDIA_CUSTOM_NAME ?? 'Wikimedia'});
|
||||||
|
}
|
||||||
|
// Wordpress.com
|
||||||
|
if (process.env.NEXT_PUBLIC_WORDPRESS_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'wordpress', name: process.env.WORDPRESS_CUSTOM_NAME ?? 'WordPress.com'});
|
||||||
|
}
|
||||||
|
// Yandex
|
||||||
|
if (process.env.NEXT_PUBLIC_YANDEX_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'yandex', name: process.env.YANDEX_CUSTOM_NAME ?? 'Yandex'});
|
||||||
|
}
|
||||||
|
// Zitadel
|
||||||
|
if (process.env.NEXT_PUBLIC_ZITADEL_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'zitadel', name: process.env.ZITADEL_CUSTOM_NAME ?? 'ZITADEL'});
|
||||||
|
}
|
||||||
|
// Zoho
|
||||||
|
if (process.env.NEXT_PUBLIC_ZOHO_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'zoho', name: process.env.ZOHO_CUSTOM_NAME ?? 'Zoho'});
|
||||||
|
}
|
||||||
|
// Zoom
|
||||||
|
if (process.env.NEXT_PUBLIC_ZOOM_ENABLED === 'true') {
|
||||||
|
buttonAuths.push({method: 'zoom', name: process.env.ZOOM_CUSTOM_NAME ?? 'Zoom'});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
credentialsEnabled: (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === 'true' || process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined) ? "true" : "false",
|
||||||
|
emailEnabled: (process.env.NEXT_PUBLIC_EMAIL_PROVIDER === 'true' ? 'true' : 'false'),
|
||||||
|
registrationDisabled: (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === 'true' ? 'true' : 'false'),
|
||||||
|
buttonAuths: buttonAuths
|
||||||
|
};
|
||||||
|
}
|
|
@ -51,7 +51,6 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
const updated = await updateUserById(userId, req.body);
|
const updated = await updateUserById(userId, req.body);
|
||||||
return res.status(updated.status).json({ response: updated.response });
|
return res.status(updated.status).json({ response: updated.response });
|
||||||
} else if (req.method === "DELETE") {
|
} else if (req.method === "DELETE") {
|
||||||
console.log(req.body);
|
|
||||||
const updated = await deleteUserById(userId, req.body);
|
const updated = await deleteUserById(userId, req.body);
|
||||||
return res.status(updated.status).json({ response: updated.response });
|
return res.status(updated.status).json({ response: updated.response });
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ŝarĝante…
Reference in New Issue