Compare commits

..

3 Commits

Author SHA1 Message Date
Daniel ae6656e0ec Merge pull request #386 from treyg/global-theming-2.4
Global theming support
2024-01-02 07:30:01 -05:00
Trey Gordon 7e9eae0ef2 style: change to neutral to handle new themes 2023-12-29 12:29:10 -05:00
Trey Gordon 6b28abc405 feat: add new theming options 2023-12-29 12:28:43 -05:00
330 changed files with 6647 additions and 24501 deletions
+11 -57
View File
@@ -1,12 +1,11 @@
NEXTAUTH_URL=http://localhost:3000/api/v1/auth NEXTAUTH_SECRET=very_sensitive_secret
NEXTAUTH_SECRET= NEXTAUTH_URL=http://localhost:3000
# Manual installation database settings # Manual installation database settings
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
DATABASE_URL=
# Docker installation database settings # Docker installation database settings
POSTGRES_PASSWORD= POSTGRES_PASSWORD=super_secret_password
# Additional Optional Settings # Additional Optional Settings
PAGINATION_TAKE_COUNT= PAGINATION_TAKE_COUNT=
@@ -16,27 +15,11 @@ NEXT_PUBLIC_DISABLE_REGISTRATION=
NEXT_PUBLIC_CREDENTIALS_ENABLED= NEXT_PUBLIC_CREDENTIALS_ENABLED=
DISABLE_NEW_SSO_USERS= DISABLE_NEW_SSO_USERS=
RE_ARCHIVE_LIMIT= RE_ARCHIVE_LIMIT=
NEXT_PUBLIC_MAX_FILE_SIZE=
MAX_LINKS_PER_USER= MAX_LINKS_PER_USER=
ARCHIVE_TAKE_COUNT= ARCHIVE_TAKE_COUNT=
BROWSER_TIMEOUT= BROWSER_TIMEOUT=
IGNORE_UNAUTHORIZED_CA= IGNORE_UNAUTHORIZED_CA=
IGNORE_HTTPS_ERRORS=
IGNORE_URL_SIZE_LIMIT=
NEXT_PUBLIC_DEMO=
NEXT_PUBLIC_DEMO_USERNAME=
NEXT_PUBLIC_DEMO_PASSWORD=
NEXT_PUBLIC_ADMIN=
NEXT_PUBLIC_MAX_FILE_BUFFER=
MONOLITH_MAX_BUFFER=
MONOLITH_CUSTOM_OPTIONS=
PDF_MAX_BUFFER=
SCREENSHOT_MAX_BUFFER=
READABILITY_MAX_BUFFER=
PREVIEW_MAX_BUFFER=
IMPORT_LIMIT=
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
MAX_WORKERS=
DISABLE_PRESERVATION=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=
@@ -50,21 +33,11 @@ SPACES_FORCE_PATH_STYLE=
NEXT_PUBLIC_EMAIL_PROVIDER= NEXT_PUBLIC_EMAIL_PROVIDER=
EMAIL_FROM= EMAIL_FROM=
EMAIL_SERVER= EMAIL_SERVER=
BASE_URL=
# Proxy settings
PROXY=
PROXY_USERNAME=
PROXY_PASSWORD=
PROXY_BYPASS=
# PDF archive settings #
PDF_MARGIN_TOP= # SSO Providers
PDF_MARGIN_BOTTOM= #
#################
# SSO Providers #
#################
# 42 School # 42 School
NEXT_PUBLIC_FORTYTWO_ENABLED= NEXT_PUBLIC_FORTYTWO_ENABLED=
@@ -92,12 +65,6 @@ AUTH0_ISSUER=
AUTH0_CLIENT_SECRET= AUTH0_CLIENT_SECRET=
AUTH0_CLIENT_ID= AUTH0_CLIENT_ID=
# Authelia
NEXT_PUBLIC_AUTHELIA_ENABLED=""
AUTHELIA_CLIENT_ID=""
AUTHELIA_CLIENT_SECRET=""
AUTHELIA_WELLKNOWN_URL=""
# Authentik # Authentik
NEXT_PUBLIC_AUTHENTIK_ENABLED= NEXT_PUBLIC_AUTHENTIK_ENABLED=
AUTHENTIK_CUSTOM_NAME= AUTHENTIK_CUSTOM_NAME=
@@ -105,25 +72,12 @@ AUTHENTIK_ISSUER=
AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET= AUTHENTIK_CLIENT_SECRET=
# Azure AD B2C
NEXT_PUBLIC_AZURE_AD_B2C_ENABLED=
AZURE_AD_B2C_TENANT_NAME=
AZURE_AD_B2C_CLIENT_ID=
AZURE_AD_B2C_CLIENT_SECRET=
AZURE_AD_B2C_PRIMARY_USER_FLOW=
# Azure AD
NEXT_PUBLIC_AZURE_AD_ENABLED=
AZURE_AD_CLIENT_ID=
AZURE_AD_CLIENT_SECRET=
AZURE_AD_TENANT_ID=
# Battle.net # Battle.net
NEXT_PUBLIC_BATTLENET_ENABLED= NEXT_PUBLIC_BATTLENET_ENABLED=
BATTLENET_CUSTOM_NAME= BATTLENET_CUSTOM_NAME=
BATTLENET_CLIENT_ID= BATTLENET_CLIENT_ID=
BATTLENET_CLIENT_SECRET= BATTLENET_CLIENT_SECRET=
BATTLENET_ISSUER= BATLLENET_ISSUER=
# Box # Box
NEXT_PUBLIC_BOX_ENABLED= NEXT_PUBLIC_BOX_ENABLED=
@@ -212,8 +166,8 @@ FUSIONAUTH_TENANT_ID=
# GitHub # GitHub
NEXT_PUBLIC_GITHUB_ENABLED= NEXT_PUBLIC_GITHUB_ENABLED=
GITHUB_CUSTOM_NAME= GITHUB_CUSTOM_NAME=
GITHUB_ID= GITHUB_CLIENT_ID=
GITHUB_SECRET= GITHUB_CLIENT_SECRET=
# GitLab # GitLab
NEXT_PUBLIC_GITLAB_ENABLED= NEXT_PUBLIC_GITLAB_ENABLED=
-18
View File
@@ -1,18 +0,0 @@
name: Check pull request source branch
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- edited
jobs:
check-branches:
runs-on: ubuntu-latest
steps:
- name: Check branches
run: |
if [ ${{ github.head_ref }} != "dev" ] && [ ${{ github.base_ref }} == "main" ]; then
echo "Merge requests to main branch are only allowed from dev branch. Please rebase your changes to dev branch."
exit 1
fi
-143
View File
@@ -1,143 +0,0 @@
name: Linkwarden Playwright Tests
on:
push:
branches:
- main
- qacomet/**
pull_request:
workflow_dispatch:
env:
PGHOST: localhost
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: password
PGDATABASE: postgres
TEST_POSTGRES_USER: test_linkwarden_user
TEST_POSTGRES_PASSWORD: password
TEST_POSTGRES_DATABASE: test_linkwarden_db
TEST_POSTGRES_DATABASE_TEMPLATE: test_linkwarden_db_template
TEST_POSTGRES_HOST: localhost
TEST_POSTGREST_PORT: 5432
PRODUCTION_POSTGRES_DATABASE: linkwarden_db
NEXTAUTH_SECRET: very_sensitive_secret
NEXTAUTH_URL: http://localhost:3000/api/v1/auth
# Manual installation database settings
DATABASE_URL: postgresql://test_linkwarden_user:password@localhost:5432/test_linkwarden_db
# Docker installation database settings
POSTGRES_PASSWORD: password
TEST_USERNAME: test-user
TEST_PASSWORD: password
jobs:
playwright-test-runner:
strategy:
matrix:
test_case: ['@login']
timeout-minutes: 20
runs-on:
- ubuntu-22.04
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: "18"
cache: 'yarn'
- name: Initialize PostgreSQL
run: |
echo "Initializing Databases"
psql -h localhost -U postgres -d postgres -c "CREATE USER ${{ env.TEST_POSTGRES_USER }} WITH PASSWORD '${{ env.TEST_POSTGRES_PASSWORD }}';"
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
- name: Install packages
run: yarn install -y
- name: Cache playwright dependencies
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: |
ffmpeg fonts-freefont-ttf fonts-ipafont-gothic fonts-tlwg-loma-otf
fonts-unifont fonts-wqy-zenhei gstreamer1.0-libav gstreamer1.0-plugins-bad
gstreamer1.0-plugins-base gstreamer1.0-plugins-good libaa1 libass9
libasyncns0 libavc1394-0 libavcodec58 libavdevice58 libavfilter7
libavformat58 libavutil56 libbluray2 libbs2b0 libcaca0 libcdio-cdda2
libcdio-paranoia2 libcdio19 libcdparanoia0 libchromaprint1 libcodec2-1.0
libdc1394-25 libdca0 libdecor-0-0 libdv4 libdvdnav4 libdvdread8 libegl-mesa0
libegl1 libevdev2 libevent-2.1-7 libfaad2 libffi7 libflac8 libflite1
libfluidsynth3 libfreeaptx0 libgles2 libgme0 libgsm1 libgssdp-1.2-0
libgstreamer-gl1.0-0 libgstreamer-plugins-bad1.0-0
libgstreamer-plugins-base1.0-0 libgstreamer-plugins-good1.0-0 libgupnp-1.2-1
libgupnp-igd-1.0-4 libharfbuzz-icu0 libhyphen0 libiec61883-0
libinstpatch-1.0-2 libjack-jackd2-0 libkate1 libldacbt-enc2 liblilv-0-0
libltc11 libmanette-0.2-0 libmfx1 libmjpegutils-2.1-0 libmodplug1
libmp3lame0 libmpcdec6 libmpeg2encpp-2.1-0 libmpg123-0 libmplex2-2.1-0
libmysofa1 libnice10 libnotify4 libopenal-data libopenal1 libopengl0
libopenh264-6 libopenmpt0 libopenni2-0 libopus0 liborc-0.4-0
libpocketsphinx3 libpostproc55 libpulse0 libqrencode4 libraw1394-11
librubberband2 libsamplerate0 libsbc1 libsdl2-2.0-0 libserd-0-0 libshine3
libshout3 libsndfile1 libsndio7.0 libsord-0-0 libsoundtouch1 libsoup-3.0-0
libsoup-3.0-common libsoxr0 libspandsp2 libspeex1 libsphinxbase3
libsratom-0-0 libsrt1.4-gnutls libsrtp2-1 libssh-gcrypt-4 libswresample3
libswscale5 libtag1v5 libtag1v5-vanilla libtheora0 libtwolame0 libudfread0
libv4l-0 libv4lconvert0 libva-drm2 libva-x11-2 libva2 libvdpau1
libvidstab1.1 libvisual-0.4-0 libvo-aacenc0 libvo-amrwbenc0 libvorbisenc2
libvpx7 libwavpack1 libwebrtc-audio-processing1 libwildmidi2 libwoff1
libx264-163 libxcb-shape0 libxv1 libxvidcore4 libzbar0 libzimg2
libzvbi-common libzvbi0 libzxingcore1 ocl-icd-libopencl1 timgm6mb-soundfont
xfonts-cyrillic xfonts-encodings xfonts-scalable xfonts-utils
- name: Cache playwright browsers
id: cache-playwright
uses: actions/cache@v4
with:
path: ~/.cache/
key: ${{ runner.os }}-playwright-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install playwright
if: steps.cache-playwright.outputs.cache-hit != 'true'
run: yarn playwright install --with-deps
- name: Setup project
run: |
yarn prisma generate
yarn build
yarn prisma migrate deploy
- name: Start linkwarden server and worker
run: yarn start &
- name: Run Tests
run: npx playwright test --grep ${{ matrix.test_case }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: test-results
retention-days: 30
+2 -3
View File
@@ -1,7 +1,6 @@
name: Create and publish a container image on release name: Create and publish a container image on release
on: on:
workflow_dispatch:
push: push:
tags: tags:
- "*" - "*"
@@ -28,7 +27,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry - name: Log in to the Container registry
uses: docker/login-action@v3 uses: docker/login-action@v2
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -41,7 +40,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v3
with: with:
context: . context: .
push: true push: true
-7
View File
@@ -42,15 +42,8 @@ prisma/dev.db
# tests # tests
/tests /tests
/test-results/ /test-results/
/blob-report/
/playwright-report/ /playwright-report/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/
# docker # docker
pgdata pgdata
certificates
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+1 -1
View File
@@ -1,6 +1,6 @@
node_modules node_modules
.next .next
/public public
*.lock *.lock
*.log *.log
+1 -6
View File
@@ -1,6 +1 @@
{ {}
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}
-45
View File
@@ -1,45 +0,0 @@
# Architecture
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
When you start Linkwarden, there are mainly two components that run:
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
## Main Tech Stack
- [NextJS](https://github.com/vercel/next.js)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
- [DaisyUI](https://github.com/saadeghi/daisyui)
- [Prisma](https://github.com/prisma/prisma)
- [Playwright](https://github.com/microsoft/playwright)
- [Zustand](https://github.com/pmndrs/zustand)
## Folder Structure
Here's a summary of the main files and folders in the project:
```
linkwarden
├── components # React components
├── hooks # React reusable hooks
├── layouts # Layouts for pages
├── lib
│   ├── api # Server-side functions (controllers, etc.)
│   ├── client # Client-side functions
│   └── shared # Shared functions between client and server
├── pages # Pages and API routes
├── prisma # Prisma schema and migrations
├── scripts
│   ├── migration # Scripts for breaking changes
│   └── worker.ts # Background worker for archiving links
├── store # Zustand stores
├── styles # Styles
└── types # TypeScript types
```
## Versioning
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).
+4 -23
View File
@@ -1,16 +1,4 @@
# Stage: monolith-builder FROM node:18.18-bullseye-slim
# Purpose: Uses the Rust image to build monolith
# Notes:
# - Fine to leave extra here, as only the resulting binary is copied out
FROM docker.io/rust:1.80-bullseye AS monolith-builder
RUN set -eux && cargo install --locked monolith
# Stage: main-app
# Purpose: Compiles the frontend and
# Notes:
# - Nothing extra should be left here. All commands should cleanup
FROM node:18.18-bullseye-slim AS main-app
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -20,15 +8,10 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./ COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \ # Increase timeout to pass github actions arm64 build
set -eux && \ RUN yarn install --network-timeout 10000000
yarn install --network-timeout 10000000
# Copy the compiled monolith binary from the builder stage RUN npx playwright install-deps && \
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
RUN set -eux && \
npx playwright install --with-deps chromium && \
apt-get clean && \ apt-get clean && \
yarn cache clean yarn cache clean
@@ -37,6 +20,4 @@ COPY . .
RUN yarn prisma generate && \ RUN yarn prisma generate && \
yarn build yarn build
EXPOSE 3000
CMD yarn prisma migrate deploy && yarn start CMD yarn prisma migrate deploy && yarn start
View File
+10 -27
View File
@@ -1,34 +1,28 @@
<div align="center"> <div align="center">
<img src="./assets/logo.png" width="100px" /> <img src="./assets/logo.png" width="100px" />
<h1>Linkwarden</h1> <h1>Linkwarden</h1>
<h3>Bookmark Preservation for Individuals and Teams</h3>
<a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a> <a href="https://discord.com/invite/CtuYV47nuJ"><img src="https://img.shields.io/discord/1117993124669702164?logo=discord&style=flat" alt="Discord"></a>
<a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a> <a href="https://news.ycombinator.com/item?id=36942308"><img src="https://img.shields.io/badge/Hacker%20News-280-%23FF6600"></img></a> <a href="https://twitter.com/LinkwardenHQ"><img src="https://img.shields.io/twitter/follow/linkwarden" alt="Twitter"></a>
<a href="https://github.com/linkwarden/linkwarden/actions/workflows/release-container.yml"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/linkwarden/linkwarden/release-container.yml"></a> <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">
<a href="https://opencollective.com/linkwarden"><img src="https://img.shields.io/opencollective/all/linkwarden" alt="Open Collective"></a>
</div> </div>
<div align='center'> <div align='center'>
[« LAUNCH DEMO »](https://demo.linkwarden.app) [Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
[Cloud](https://cloud.linkwarden.app) · [Website](https://linkwarden.app) · [Features](https://github.com/linkwarden/linkwarden#features)
</div> </div>
## Intro & motivation ## Intro & motivation
**Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** **Linkwarden is a self-hosted, open-source collaborative bookmark manager to collect, organize and archive webpages.** The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
The objective is to organize useful webpages and articles you find across the web in one place, and since useful webpages can go away (see the inevitability of [Link Rot](https://www.howtogeek.com/786227/what-is-link-rot-and-how-does-it-threaten-the-web/)), Linkwarden also saves a copy of each webpage as a Screenshot and PDF, ensuring accessibility even if the original content is no longer available.
Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly. Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly.
> [!TIP] > [!TIP]
> Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer self-hosting Linkwarden, you can do so by following our [Installation documentation](https://docs.linkwarden.app/self-hosting/installation). > Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits. <br> Your subscription supports our hosting infrastructure and ongoing development. <br> Alternatively, if you prefer [self-hosting](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features.
<img src="./assets/dashboard.png" /> <img src="./assets/dashboard.png" />
@@ -61,9 +55,9 @@ We've forked the old version from the current repository into [this repo](https:
## Features ## Features
- 📸 Auto capture a screenshot, PDF, single html file, and readable view of each webpage. - 📸 Auto capture a screenshot, PDF, and readable view of each webpage.
- 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional) - 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional)
- 📂 Organize links by collection, sub-collection, name, description and multiple tags. - 📂 Organize links by collection, name, description and multiple tags.
- 👥 Collaborate on gathering links in a collection. - 👥 Collaborate on gathering links in a collection.
- 🎛️ Customize the permissions of each member. - 🎛️ Customize the permissions of each member.
- 🌐 Share your collected links and preserved formats with the world. - 🌐 Share your collected links and preserved formats with the world.
@@ -72,20 +66,9 @@ 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)
- 🔄 Browser Synchronization (using [Floccus](https://floccus.org)!)
- ⬇️ Import and export your bookmarks. - ⬇️ Import and export your bookmarks.
- 🔐 SSO integration. (Enterprise and Self-hosted users only) - 🔐 SSO integration. (Enterprise and Self-hosted users only)
- 📦 Installable Progressive Web App (PWA). - ✨ And so many more features!
- 🍏 iOS and MacOS Apps, maintained by [JGeek00](https://github.com/JGeek00).
- 🍎 iOS Shortcut to save Links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- 👥 User administration.
- 🌐 Support for Other Languages (i18n).
- 📁 Image and PDF Uploads.
- 🎨 Custom Icons for Links and Collections.
- ⚙️ Customizable View and Adjustable Columns.
- ✨ And many more features. (Literally!)
## Like what we're doing? Give us a Star ⭐ ## Like what we're doing? Give us a Star ⭐
@@ -109,7 +92,7 @@ We _usually_ go after the [popular suggestions](https://github.com/linkwarden/li
Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1). Make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1).
## Documentation ## Docs
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).
@@ -121,7 +104,7 @@ If you want to contribute, Thanks! Start by checking our [public roadmap](https:
If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks! If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks!
## Support <3 ## Support
Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well! Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well!
+29
View File
@@ -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>
);
}
-39
View File
@@ -1,39 +0,0 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
import { Trans } from "next-i18next";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function Announcement({ toggleAnnouncementBar }: Props) {
const announcementId = localStorage.getItem("announcementId");
return (
<div className="fixed mx-auto bottom-20 sm:bottom-10 w-full pointer-events-none p-5 z-30">
<div className="mx-auto pointer-events-auto p-2 flex justify-between gap-2 items-center border border-primary shadow-xl rounded-xl bg-base-300 backdrop-blur-sm bg-opacity-80 max-w-md">
<i className="bi-stars text-2xl text-yellow-600 dark:text-yellow-500"></i>
<p className="w-4/5 text-center text-sm sm:text-base">
<Trans
i18nKey="new_version_announcement"
values={{ version: announcementId }}
components={[
<Link
href={`https://blog.linkwarden.app/releases/${announcementId}`}
target="_blank"
className="underline"
key={0}
/>,
]}
/>
</p>
<button
onClick={toggleAnnouncementBar}
className="btn btn-ghost btn-square btn-sm"
>
<i className="bi-x text-xl"></i>
</button>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import React, { MouseEventHandler } from "react";
type Props = {
toggleAnnouncementBar: MouseEventHandler<HTMLButtonElement>;
};
export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
return (
<div className="fixed w-full z-20 bg-base-200">
<div className="w-full h-10 rainbow flex items-center justify-center">
<div className="w-fit font-semibold">
🎉 See what&apos;s new in{" "}
<Link
href="https://blog.linkwarden.app/releases/v2.4"
target="_blank"
className="underline hover:opacity-50 duration-100"
>
Linkwarden v2.4
</Link>
! 🥳
</div>
<button
className="fixed right-3 hover:opacity-50 duration-100"
onClick={toggleAnnouncementBar}
>
<i className="bi-x text-neutral text-2xl"></i>
</button>
</div>
</div>
);
}
+6 -25
View File
@@ -8,38 +8,19 @@ type Props = {
onMount?: (rect: DOMRect) => void; onMount?: (rect: DOMRect) => void;
}; };
function getZIndex(element: HTMLElement): number {
let zIndex = 0;
while (element) {
const zIndexStyle = window
.getComputedStyle(element)
.getPropertyValue("z-index");
const numericZIndex = Number(zIndexStyle);
if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) {
zIndex = numericZIndex;
break;
}
element = element.parentElement as HTMLElement;
}
return zIndex;
}
function useOutsideAlerter( function useOutsideAlerter(
ref: RefObject<HTMLElement>, ref: RefObject<HTMLElement>,
onClickOutside: Function onClickOutside: Function
) { ) {
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: Event) {
const clickedElement = event.target as HTMLElement; if (
if (ref.current && !ref.current.contains(clickedElement)) { ref.current &&
const refZIndex = getZIndex(ref.current); !ref.current.contains(event.target as HTMLInputElement)
const clickedZIndex = getZIndex(clickedElement); ) {
if (clickedZIndex <= refZIndex) { onClickOutside(event);
onClickOutside(event);
}
} }
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
+43 -52
View File
@@ -1,28 +1,23 @@
import Link from "next/link"; import Link from "next/link";
import { import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@/types/global";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto"; import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import EditCollectionModal from "./ModalContent/EditCollectionModal"; import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal"; import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal"; import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
export default function CollectionCard({ type Props = {
collection,
}: {
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
}) { className?: string;
const { t } = useTranslation(); };
export default function CollectionCard({ collection, className }: Props) {
const { settings } = useLocalSettingsStore(); const { settings } = useLocalSettingsStore();
const { data: user = {} } = useUser(); const { account } = useAccountStore();
const formattedDate = new Date(collection.createdAt as string).toLocaleString( const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US", "en-US",
@@ -35,24 +30,28 @@ export default function CollectionCard({
const permissions = usePermissions(collection.id as number); const permissions = usePermissions(collection.id as number);
const [collectionOwner, setCollectionOwner] = useState< const [collectionOwner, setCollectionOwner] = useState({
Partial<AccountSettings> id: null as unknown as number,
>({}); name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
if (collection && collection.ownerId !== user.id) { if (collection && collection.ownerId !== account.id) {
const owner = await getPublicUserData(collection.ownerId as number); const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner); setCollectionOwner(owner);
} else if (collection && collection.ownerId === user.id) { } else if (collection && collection.ownerId === account.id) {
setCollectionOwner({ setCollectionOwner({
id: user.id as number, id: account.id as number,
name: user.name, name: account.name,
username: user.username as string, username: account.username as string,
image: user.image as string, image: account.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean, archiveAsScreenshot: account.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsMonolith as boolean, archiveAsPDF: account.archiveAsPDF as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
}); });
} }
}; };
@@ -71,13 +70,12 @@ export default function CollectionCard({
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-ghost btn-sm btn-square text-neutral" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<i className="bi-three-dots text-xl" title="More"></i> <i className="bi-three-dots text-xl" title="More"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1"> <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
{permissions === true && ( {permissions === true ? (
<li> <li>
<div <div
role="button" role="button"
@@ -86,12 +84,11 @@ export default function CollectionCard({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true); setEditCollectionModal(true);
}} }}
className="whitespace-nowrap"
> >
{t("edit_collection_info")} Edit Collection Info
</div> </div>
</li> </li>
)} ) : undefined}
<li> <li>
<div <div
role="button" role="button"
@@ -100,11 +97,8 @@ export default function CollectionCard({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true); setEditCollectionSharingModal(true);
}} }}
className="whitespace-nowrap"
> >
{permissions === true {permissions === true ? "Share and Collaborate" : "View Team"}
? t("share_and_collaborate")
: t("view_team")}
</div> </div>
</li> </li>
<li> <li>
@@ -115,11 +109,8 @@ export default function CollectionCard({
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true); setDeleteCollectionModal(true);
}} }}
className="whitespace-nowrap"
> >
{permissions === true {permissions === true ? "Delete Collection" : "Leave Collection"}
? t("delete_collection")
: t("leave_collection")}
</div> </div>
</li> </li>
</ul> </ul>
@@ -128,12 +119,12 @@ export default function CollectionCard({
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full" className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)} onClick={() => setEditCollectionSharingModal(true)}
> >
{collectionOwner.id && ( {collectionOwner.id ? (
<ProfilePhoto <ProfilePhoto
src={collectionOwner.image || undefined} src={collectionOwner.image || undefined}
name={collectionOwner.name} name={collectionOwner.name}
/> />
)} ) : undefined}
{collection.members {collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number)) .sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => { .map((e, i) => {
@@ -147,13 +138,13 @@ export default function CollectionCard({
); );
}) })
.slice(0, 3)} .slice(0, 3)}
{collection.members.length - 3 > 0 && ( {collection.members.length - 3 > 0 ? (
<div className={`avatar drop-shadow-md placeholder -ml-3`}> <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"> <div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span> <span>+{collection.members.length - 3}</span>
</div> </div>
</div> </div>
)} ) : null}
</div> </div>
<Link <Link
href={`/collections/${collection.id}`} href={`/collections/${collection.id}`}
@@ -177,12 +168,12 @@ export default function CollectionCard({
<div className="flex justify-end items-center"> <div className="flex justify-end items-center">
<div className="text-right"> <div className="text-right">
<div className="font-bold text-sm flex justify-end gap-1 items-center"> <div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic && ( {collection.isPublic ? (
<i <i
className="bi-globe2 drop-shadow text-neutral" className="bi-globe-americas drop-shadow text-neutral"
title="This collection is being shared publicly." title="This collection is being shared publicly."
></i> ></i>
)} ) : undefined}
<i <i
className="bi-link-45deg text-lg text-neutral" className="bi-link-45deg text-lg text-neutral"
title="This collection is being shared publicly." title="This collection is being shared publicly."
@@ -202,24 +193,24 @@ export default function CollectionCard({
</div> </div>
</div> </div>
</Link> </Link>
{editCollectionModal && ( {editCollectionModal ? (
<EditCollectionModal <EditCollectionModal
onClose={() => setEditCollectionModal(false)} onClose={() => setEditCollectionModal(false)}
activeCollection={collection} activeCollection={collection}
/> />
)} ) : undefined}
{editCollectionSharingModal && ( {editCollectionSharingModal ? (
<EditCollectionSharingModal <EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)} onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection} activeCollection={collection}
/> />
)} ) : undefined}
{deleteCollectionModal && ( {deleteCollectionModal ? (
<DeleteCollectionModal <DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)} onClose={() => setDeleteCollectionModal(false)}
activeCollection={collection} activeCollection={collection}
/> />
)} ) : undefined}
</div> </div>
); );
} }
-398
View File
@@ -1,398 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import Tree, {
mutateTree,
moveItemOnTree,
RenderItemParams,
TreeItem,
TreeData,
ItemId,
TreeSourcePosition,
TreeDestinationPosition,
} from "@atlaskit/tree";
import { Collection } from "@prisma/client";
import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
import { useUpdateUser, useUser } from "@/hooks/store/user";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { t } = useTranslation();
const updateCollection = useUpdateCollection();
const { data: collections = [], isLoading } = useCollections();
const { data: user = {}, refetch } = useUser();
const updateUser = useUpdateUser();
const router = useRouter();
const currentPath = router.asPath;
const [tree, setTree] = useState<TreeData | undefined>();
const initialTree = useMemo(() => {
if (collections.length > 0) {
return buildTreeFromCollections(
collections,
router,
tree,
user.collectionOrder
);
} else return undefined;
}, [collections, user, router]);
useEffect(() => {
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (user.username) {
refetch();
if (
(!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0
)
updateUser.mutate({
...user,
collectionOrder: collections
.filter((e) => e.parentId === null)
.map((e) => e.id as number),
});
else {
const newCollectionOrder: number[] = [...(user.collectionOrder || [])];
// Start with collections that are in both account.collectionOrder and collections
const existingCollectionIds = collections.map((c) => c.id as number);
const filteredCollectionOrder = user.collectionOrder.filter((id: any) =>
existingCollectionIds.includes(id)
);
// Add new collections that are not in account.collectionOrder and meet the specific conditions
collections.forEach((collection) => {
if (
!filteredCollectionOrder.includes(collection.id as number) &&
(!collection.parentId || collection.ownerId === user.id)
) {
filteredCollectionOrder.push(collection.id as number);
}
});
// check if the newCollectionOrder is the same as the old one
if (
JSON.stringify(newCollectionOrder) !==
JSON.stringify(user.collectionOrder)
) {
updateUser.mutateAsync({
...user,
collectionOrder: newCollectionOrder,
});
}
}
}
}, [user, collections]);
const onExpand = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
mutateTree(currentTree!, movedCollectionId, { isExpanded: true })
);
};
const onCollapse = (movedCollectionId: ItemId) => {
setTree((currentTree) =>
mutateTree(currentTree as TreeData, movedCollectionId, {
isExpanded: false,
})
);
};
const onDragEnd = async (
source: TreeSourcePosition,
destination: TreeDestinationPosition | undefined
) => {
if (!destination || !tree) {
return;
}
if (
source.index === destination.index &&
source.parentId === destination.parentId
) {
return;
}
const movedCollectionId = Number(
tree.items[source.parentId].children[source.index]
);
const movedCollection = collections.find((c) => c.id === movedCollectionId);
const destinationCollection = collections.find(
(c) => c.id === Number(destination.parentId)
);
if (
(movedCollection?.ownerId !== user.id &&
destination.parentId !== source.parentId) ||
(destinationCollection?.ownerId !== user.id &&
destination.parentId !== "root")
) {
return toast.error(t("cant_change_collection_you_dont_own"));
}
setTree((currentTree) => moveItemOnTree(currentTree!, source, destination));
const updatedCollectionOrder = [...user.collectionOrder];
if (source.parentId !== destination.parentId) {
await updateCollection.mutateAsync(
{
...movedCollection,
parentId:
destination.parentId && destination.parentId !== "root"
? Number(destination.parentId)
: destination.parentId === "root"
? "root"
: null,
},
{
onError: (error) => {
toast.error(error.message);
},
}
);
}
if (
destination.index !== undefined &&
destination.parentId === source.parentId &&
source.parentId === "root"
) {
updatedCollectionOrder.includes(movedCollectionId) &&
updatedCollectionOrder.splice(source.index, 1);
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
await updateUser.mutateAsync({
...user,
collectionOrder: updatedCollectionOrder,
});
} else if (
destination.index !== undefined &&
destination.parentId === "root"
) {
updatedCollectionOrder.splice(destination.index, 0, movedCollectionId);
updateUser.mutate({
...user,
collectionOrder: updatedCollectionOrder,
});
} else if (
source.parentId === "root" &&
destination.parentId &&
destination.parentId !== "root"
) {
updatedCollectionOrder.splice(source.index, 1);
await updateUser.mutateAsync({
...user,
collectionOrder: updatedCollectionOrder,
});
}
};
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
);
} else if (!tree) {
return (
<p className="text-neutral text-xs font-semibold truncate w-full px-2 mt-5 mb-8">
{t("you_have_no_collections")}
</p>
);
} else
return (
<Tree
tree={tree}
renderItem={(itemProps) => renderItem({ ...itemProps }, currentPath)}
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
isDragEnabled
isNestingEnabled
/>
);
};
export default CollectionListing;
const renderItem = (
{ item, onExpand, onCollapse, provided }: RenderItemParams,
currentPath: string
) => {
const collection = item.data;
return (
<div ref={provided.innerRef} {...provided.draggableProps} className="mb-1">
<div
className={`${
currentPath === `/collections/${collection.id}`
? "bg-primary/20 is-active"
: "hover:bg-neutral/20"
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
>
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
className="w-full"
{...provided.dragHandleProps}
>
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
{collection.icon ? (
<Icon
icon={collection.icon}
size={30}
weight={(collection.iconWeight || "regular") as IconWeight}
color={collection.color}
className="-mr-[0.15rem]"
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: collection.color }}
></i>
)}
<p className="truncate w-full">{collection.name}</p>
{collection.isPublic && (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
)}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
</div>
</Link>
</div>
</div>
);
};
const Dropdown = (
item: ExtendedTreeItem,
onExpand: (id: ItemId) => void,
onCollapse: (id: ItemId) => void
) => {
if (item.children && item.children.length > 0) {
return item.isExpanded ? (
<button onClick={() => onCollapse(item.id)}>
<div className="bi-caret-down-fill opacity-50 hover:opacity-100 duration-200"></div>
</button>
) : (
<button onClick={() => onExpand(item.id)}>
<div className="bi-caret-right-fill opacity-40 hover:opacity-100 duration-200"></div>
</button>
);
}
// return <span>&bull;</span>;
return <div></div>;
};
const buildTreeFromCollections = (
collections: CollectionIncludingMembersAndLinkCount[],
router: ReturnType<typeof useRouter>,
tree?: TreeData,
order?: number[]
): TreeData => {
if (order) {
collections.sort((a: any, b: any) => {
return order.indexOf(a.id) - order.indexOf(b.id);
});
}
const items: { [key: string]: ExtendedTreeItem } = collections.reduce(
(acc: any, collection) => {
acc[collection.id as number] = {
id: collection.id,
children: [],
hasChildren: false,
isExpanded: tree?.items[collection.id as number]?.isExpanded || false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
isPublic: collection.isPublic,
ownerId: collection.ownerId,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
_count: {
links: collection._count?.links,
},
},
};
return acc;
},
{}
);
const activeCollectionId = Number(router.asPath.split("/collections/")[1]);
if (activeCollectionId) {
for (const item in items) {
const collection = items[item];
if (Number(item) === activeCollectionId && collection.data.parentId) {
// get all the parents of the active collection recursively until root and set isExpanded to true
let parentId = collection.data.parentId || null;
while (parentId && items[parentId]) {
items[parentId].isExpanded = true;
parentId = items[parentId].data.parentId;
}
}
}
}
collections.forEach((collection) => {
const parentId = collection.parentId;
if (parentId && items[parentId] && collection.id) {
items[parentId].children.push(collection.id);
items[parentId].hasChildren = true;
}
});
const rootId = "root";
items[rootId] = {
id: rootId,
children: (collections
.filter(
(c) =>
c.parentId === null || !collections.find((i) => i.id === c.parentId)
)
.map((c) => c.id) || "") as unknown as string[],
hasChildren: true,
isExpanded: true,
data: { name: "Root" } as Collection,
};
return { rootId, items };
};
-32
View File
@@ -1,32 +0,0 @@
import React, { useState } from "react";
type Props = {
text: string;
};
const CopyButton: React.FC<Props> = ({ text }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (err) {
console.log(err);
}
};
return (
<div
className={`text-xl text-neutral btn btn-sm btn-square btn-ghost ${
copied ? "bi-check2 text-success" : "bi-copy"
}`}
onClick={handleCopy}
></div>
);
};
export default CopyButton;
+5 -7
View File
@@ -8,15 +8,13 @@ export default function dashboardItem({
icon: string; icon: string;
}) { }) {
return ( return (
<div className="flex items-center justify-between w-full rounded-2xl border border-neutral-content p-3 bg-gradient-to-tr from-neutral-content/70 to-50% to-base-200"> <div className="flex items-center">
<div className="w-14 aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none"> <div className="w-[4.7rem] aspect-square flex justify-center items-center bg-primary/20 rounded-xl select-none">
<i className={`${icon} text-primary text-3xl drop-shadow`}></i> <i className={`${icon} text-primary text-4xl drop-shadow`}></i>
</div> </div>
<div className="ml-4 flex flex-col justify-center"> <div className="ml-4 flex flex-col justify-center">
<p className="text-neutral text-xs tracking-wider text-right">{name}</p> <p className="text-neutral text-xs tracking-wider">{name}</p>
<p className="font-thin text-4xl text-primary mt-0.5 text-right"> <p className="font-thin text-6xl text-primary mt-0.5">{value}</p>
{value || 0}
</p>
</div> </div>
</div> </div>
); );
-81
View File
@@ -1,81 +0,0 @@
import React, { ReactNode, useEffect } from "react";
import { Drawer as D } from "vaul";
import clsx from "clsx";
type Props = {
toggleDrawer: Function;
children: ReactNode;
className?: string;
dismissible?: boolean;
};
export default function Drawer({
toggleDrawer,
className,
children,
dismissible = true,
}: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
useEffect(() => {
if (window.innerWidth >= 640) {
document.body.style.overflow = "hidden";
document.body.style.position = "relative";
return () => {
document.body.style.overflow = "auto";
document.body.style.position = "";
};
}
}, []);
if (window.innerWidth < 640) {
return (
<D.Root
open={drawerIsOpen}
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
dismissible={dismissible}
>
<D.Portal>
<D.Overlay className="fixed inset-0 bg-black/40" />
<D.Content className="flex flex-col rounded-t-2xl mt-24 fixed bottom-0 left-0 right-0 z-30 h-[90%] !select-auto focus:outline-none">
<div
className={clsx(
"p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto",
className
)}
data-testid="mobile-modal-container"
>
<div data-testid="mobile-modal-slider" />
{children}
</div>
</D.Content>
</D.Portal>
</D.Root>
);
} else {
return (
<D.Root
open={drawerIsOpen}
onClose={() => dismissible && setDrawerIsOpen(false)}
onAnimationEnd={(isOpen) => !isOpen && toggleDrawer()}
dismissible={dismissible}
direction="right"
>
<D.Portal>
<D.Overlay className="fixed inset-0 bg-black/10 z-20" />
<D.Content className="bg-white flex flex-col h-full w-2/5 min-w-[30rem] mt-24 fixed bottom-0 right-0 z-40 !select-auto focus:outline-none">
<div
className={clsx(
"p-4 bg-base-100 flex-1 border-neutral-content border-l overflow-y-auto",
className
)}
>
{children}
</div>
</D.Content>
</D.Portal>
</D.Root>
);
}
}
+40 -42
View File
@@ -60,49 +60,47 @@ export default function Dropdown({
} }
}, [points, dropdownHeight]); }, [points, dropdownHeight]);
return ( return !points || pos ? (
(!points || pos) && ( <ClickAwayHandler
<ClickAwayHandler onMount={(e) => {
onMount={(e) => { setDropdownHeight(e.height);
setDropdownHeight(e.height); setDropdownWidth(e.width);
setDropdownWidth(e.width); }}
}} style={
style={ points
points ? {
? { position: "fixed",
position: "fixed", top: `${pos?.y}px`,
top: `${pos?.y}px`, left: `${pos?.x}px`,
left: `${pos?.x}px`, }
} : undefined
: undefined }
} onClickOutside={onClickOutside}
onClickOutside={onClickOutside} className={`${
className={`${ className || ""
className || "" } py-1 shadow-md border border-neutral-content bg-base-200 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-base-100 duration-100">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100"> <p className="select-none">{e.name}</p>
<p className="select-none">{e.name}</p>
</div>
</div> </div>
); </div>
);
return e && e.href ? ( return e && e.href ? (
<Link key={i} href={e.href}> <Link key={i} href={e.href}>
{inner}
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
{inner} {inner}
</Link> </div>
) : ( )
e && ( );
<div key={i} onClick={e.onClick}> })}
{inner} </ClickAwayHandler>
</div> ) : null;
)
);
})}
</ClickAwayHandler>
)
);
} }
+28 -45
View File
@@ -1,8 +1,4 @@
import { dropdownTriggerer } from "@/lib/client/utils"; import React from "react";
import React, { useEffect } from "react";
import { useTranslation } from "next-i18next";
import { resetInfiniteQueryPagination } from "@/hooks/store/links";
import { useQueryClient } from "@tanstack/react-query";
type Props = { type Props = {
setSearchFilter: Function; setSearchFilter: Function;
@@ -19,20 +15,16 @@ export default function FilterSearchDropdown({
setSearchFilter, setSearchFilter,
searchFilter, searchFilter,
}: Props) { }: Props) {
const { t } = useTranslation();
const queryClient = useQueryClient();
return ( return (
<div className="dropdown dropdown-bottom dropdown-end"> <div className="dropdown dropdown-bottom dropdown-end">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-square btn-ghost" className="btn btn-sm btn-square btn-ghost"
> >
<i className="bi-funnel text-neutral text-2xl"></i> <i className="bi-funnel text-neutral text-2xl"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@@ -45,11 +37,10 @@ export default function FilterSearchDropdown({
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
checked={searchFilter.name} checked={searchFilter.name}
onChange={() => { onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, name: !searchFilter.name }); setSearchFilter({ ...searchFilter, name: !searchFilter.name });
}} }}
/> />
<span className="label-text whitespace-nowrap">{t("name")}</span> <span className="label-text">Name</span>
</label> </label>
</li> </li>
<li> <li>
@@ -64,11 +55,10 @@ export default function FilterSearchDropdown({
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
checked={searchFilter.url} checked={searchFilter.url}
onChange={() => { onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, url: !searchFilter.url }); setSearchFilter({ ...searchFilter, url: !searchFilter.url });
}} }}
/> />
<span className="label-text whitespace-nowrap">{t("link")}</span> <span className="label-text">Link</span>
</label> </label>
</li> </li>
<li> <li>
@@ -83,16 +73,34 @@ export default function FilterSearchDropdown({
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
checked={searchFilter.description} checked={searchFilter.description}
onChange={() => { onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ setSearchFilter({
...searchFilter, ...searchFilter,
description: !searchFilter.description, description: !searchFilter.description,
}); });
}} }}
/> />
<span className="label-text whitespace-nowrap"> <span className="label-text">Description</span>
{t("description")} </label>
</span> </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> </label>
</li> </li>
<li> <li>
@@ -107,38 +115,13 @@ export default function FilterSearchDropdown({
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
checked={searchFilter.tags} checked={searchFilter.tags}
onChange={() => { onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags });
}}
/>
<span className="label-text whitespace-nowrap">{t("tags")}</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-between"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSearchFilter({ setSearchFilter({
...searchFilter, ...searchFilter,
textContent: !searchFilter.textContent, tags: !searchFilter.tags,
}); });
}} }}
/> />
<span className="label-text whitespace-nowrap"> <span className="label-text">Tags</span>
{t("full_content")}
</span>
<div className="ml-auto badge badge-sm badge-neutral whitespace-nowrap">
{t("slower")}
</div>
</label> </label>
</li> </li>
</ul> </ul>
-18
View File
@@ -1,18 +0,0 @@
import React, { forwardRef } from "react";
import * as Icons from "@phosphor-icons/react";
type Props = {
icon: string;
} & Icons.IconProps;
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
const IconComponent: any = Icons[icon as keyof typeof Icons];
if (!IconComponent) {
return null;
} else return <IconComponent ref={ref} {...rest} />;
});
Icon.displayName = "Icon";
export default Icon;
-91
View File
@@ -1,91 +0,0 @@
import { icons } from "@/lib/client/icons";
import Fuse from "fuse.js";
import { forwardRef, useMemo } from "react";
import { FixedSizeGrid as Grid } from "react-window";
const fuse = new Fuse(icons, {
keys: [{ name: "name", weight: 4 }, "tags", "categories"],
threshold: 0.2,
useExtendedSearch: true,
});
type Props = {
query: string;
color: string;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
iconName?: string;
setIconName: Function;
};
const IconGrid = ({ query, color, weight, iconName, setIconName }: Props) => {
const filteredIcons = useMemo(() => {
if (!query) {
return icons;
}
return fuse.search(query).map((result) => result.item);
}, [query]);
const columnCount = 6;
const rowCount = Math.ceil(filteredIcons.length / columnCount);
const GUTTER_SIZE = 5;
const Cell = ({ columnIndex, rowIndex, style }: any) => {
const index = rowIndex * columnCount + columnIndex;
if (index >= filteredIcons.length) return null; // Prevent overflow
const icon = filteredIcons[index];
const IconComponent = icon.Icon;
return (
<div
style={{
...style,
left: style.left + GUTTER_SIZE,
top: style.top + GUTTER_SIZE,
width: style.width - GUTTER_SIZE,
height: style.height - GUTTER_SIZE,
}}
onClick={() => setIconName(icon.pascal_name)}
className={`cursor-pointer p-[6px] rounded-lg bg-base-100 w-full ${
icon.pascal_name === iconName
? "outline outline-1 outline-primary"
: ""
}`}
>
<IconComponent size={32} weight={weight} color={color} />
</div>
);
};
const InnerElementType = forwardRef(({ style, ...rest }: any, ref) => (
<div
ref={ref}
style={{
...style,
paddingLeft: GUTTER_SIZE,
paddingTop: GUTTER_SIZE,
}}
{...rest}
/>
));
InnerElementType.displayName = "InnerElementType";
return (
<Grid
columnCount={columnCount}
rowCount={rowCount}
columnWidth={50}
rowHeight={50}
innerElementType={InnerElementType}
width={320}
height={158}
itemData={filteredIcons}
className="hide-scrollbar ml-[4px] w-fit"
>
{Cell}
</Grid>
);
};
export default IconGrid;
-83
View File
@@ -1,83 +0,0 @@
import React, { useState } from "react";
import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import IconGrid from "./IconGrid";
import IconPopover from "./IconPopover";
import clsx from "clsx";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
hideDefaultIcon?: boolean;
reset: Function;
className?: string;
};
const IconPicker = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
hideDefaultIcon,
className,
reset,
}: Props) => {
const { t } = useTranslation();
const [iconPicker, setIconPicker] = useState(false);
return (
<div className="relative">
<div
onClick={() => setIconPicker(!iconPicker)}
className="btn btn-square w-20 h-20"
>
{iconName ? (
<Icon
icon={iconName}
size={60}
weight={(weight || "regular") as IconWeight}
color={color || "#0ea5e9"}
/>
) : !iconName && hideDefaultIcon ? (
<p className="p-1">{t("set_custom_icon")}</p>
) : (
<i
className="bi-folder-fill text-6xl"
style={{ color: color || "#0ea5e9" }}
></i>
)}
</div>
{iconPicker && (
<IconPopover
alignment={alignment}
color={color}
setColor={setColor}
iconName={iconName}
setIconName={setIconName}
weight={weight}
setWeight={setWeight}
reset={reset}
onClose={() => setIconPicker(false)}
className={clsx(
className,
alignment || "lg:-translate-x-1/3 top-20 left-0"
)}
/>
)}
</div>
);
};
export default IconPicker;
-147
View File
@@ -1,147 +0,0 @@
import React, { useState } from "react";
import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import IconGrid from "./IconGrid";
import clsx from "clsx";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
reset: Function;
className?: string;
onClose: Function;
};
const IconPopover = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
reset,
className,
onClose,
}: Props) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
return (
<Popover
onClose={() => onClose()}
className={clsx(
className,
"fade-in bg-base-200 border border-neutral-content p-3 w-[22.5rem] rounded-lg shadow-md"
)}
>
<div className="flex flex-col gap-3 w-full h-full">
<TextInput
className="p-2 rounded w-full h-7 text-sm"
placeholder={t("search")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="grid grid-cols-6 gap-1 w-full overflow-y-auto h-44 border border-neutral-content bg-base-100 rounded-md p-2">
<IconGrid
query={query}
color={color}
weight={weight}
iconName={iconName}
setIconName={setIconName}
/>
</div>
<div className="flex gap-3 color-picker w-full justify-between">
<HexColorPicker
color={color}
onChange={(e) => setColor(e)}
className="border border-neutral-content rounded-lg"
/>
<div className="grid grid-cols-2 gap-3 text-sm">
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="regular"
checked={weight === "regular"}
onChange={() => setWeight("regular")}
/>
{t("regular")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="thin"
checked={weight === "thin"}
onChange={() => setWeight("thin")}
/>
{t("thin")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="light"
checked={weight === "light"}
onChange={() => setWeight("light")}
/>
{t("light_icon")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="bold"
checked={weight === "bold"}
onChange={() => setWeight("bold")}
/>
{t("bold")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="fill"
checked={weight === "fill"}
onChange={() => setWeight("fill")}
/>
{t("fill")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="duotone"
checked={weight === "duotone"}
onChange={() => setWeight("duotone")}
/>
{t("duotone")}
</label>
</div>
</div>
<div className="flex flex-row gap-2 justify-between items-center mt-2">
<div
className="btn btn-ghost btn-xs w-fit"
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
>
{t("reset_defaults")}
</div>
<p className="text-neutral text-xs">{t("click_out_to_apply")}</p>
</div>
</div>
</Popover>
);
};
export default IconPopover;
+17 -99
View File
@@ -1,35 +1,22 @@
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 { styles } from "./styles"; import { styles } from "./styles";
import { Options } from "./types"; import { Options } from "./types";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import Select from "react-select";
import { useCollections } from "@/hooks/store/collections";
type Props = { type Props = {
onChange: any; onChange: any;
showDefaultValue?: boolean; defaultValue:
defaultValue?:
| { | {
label: string; label: string;
value?: number; value?: number;
} }
| undefined; | undefined;
creatable?: boolean;
autoFocus?: boolean;
onBlur?: any;
}; };
export default function CollectionSelection({ export default function CollectionSelection({ onChange, defaultValue }: Props) {
onChange, const { collections } = useCollectionStore();
defaultValue,
showDefaultValue = true,
creatable = true,
autoFocus,
onBlur,
}: Props) {
const { data: collections = [] } = useCollections();
const router = useRouter(); const router = useRouter();
const [options, setOptions] = useState<Options[]>([]); const [options, setOptions] = useState<Options[]>([]);
@@ -49,91 +36,22 @@ export default function CollectionSelection({
useEffect(() => { useEffect(() => {
const formatedCollections = collections.map((e) => { const formatedCollections = collections.map((e) => {
return { return { value: e.id, label: e.name, ownerId: e.ownerId };
value: e.id,
label: e.name,
ownerId: e.ownerId,
count: e._count,
parentId: e.parentId,
};
}); });
setOptions(formatedCollections); setOptions(formatedCollections);
}, [collections]); }, [collections]);
const getParentNames = (parentId: number): string[] => { return (
const parentNames = []; <CreatableSelect
const parent = collections.find((e) => e.id === parentId); isClearable={false}
className="react-select-container"
if (parent) { classNamePrefix="react-select"
parentNames.push(parent.name); onChange={onChange}
if (parent.parentId) { options={options}
parentNames.push(...getParentNames(parent.parentId)); styles={styles}
} defaultValue={defaultValue}
} // menuPosition="fixed"
/>
// Have the top level parent at beginning );
return parentNames.reverse();
};
const customOption = ({ data, innerProps }: any) => {
return (
<div
{...innerProps}
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content duration-100 cursor-pointer"
>
<div className="flex w-full justify-between items-center">
<span>{data.label}</span>
<span className="text-sm text-neutral">{data.count?.links}</span>
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
{getParentNames(data?.parentId).length > 0 ? (
<>
{getParentNames(data.parentId).join(" > ")} {">"} {data.label}
</>
) : (
data.label
)}
</div>
</div>
);
};
if (creatable) {
return (
<CreatableSelect
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
onBlur={onBlur}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
} else {
return (
<Select
isClearable={false}
className="react-select-container"
classNamePrefix="react-select"
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
defaultValue={showDefaultValue ? defaultValue : null}
onBlur={onBlur}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
}
} }
+6 -14
View File
@@ -1,31 +1,24 @@
import useTagStore from "@/store/tags";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import { styles } from "./styles"; import { styles } from "./styles";
import { Options } from "./types"; import { Options } from "./types";
import { useTags } from "@/hooks/store/tags";
type Props = { type Props = {
onChange: any; onChange: any;
defaultValue?: { defaultValue?: {
value?: number; value: number;
label: string; label: string;
}[]; }[];
autoFocus?: boolean;
onBlur?: any;
}; };
export default function TagSelection({ export default function TagSelection({ onChange, defaultValue }: Props) {
onChange, const { tags } = useTagStore();
defaultValue,
autoFocus,
onBlur,
}: Props) {
const { data: tags = [] } = useTags();
const [options, setOptions] = useState<Options[]>([]); const [options, setOptions] = useState<Options[]>([]);
useEffect(() => { useEffect(() => {
const formatedCollections = tags.map((e: any) => { const formatedCollections = tags.map((e) => {
return { value: e.id, label: e.name }; return { value: e.id, label: e.name };
}); });
@@ -41,9 +34,8 @@ export default function TagSelection({
options={options} options={options}
styles={styles} styles={styles}
defaultValue={defaultValue} defaultValue={defaultValue}
// menuPosition="fixed"
isMulti isMulti
autoFocus={autoFocus}
onBlur={onBlur}
/> />
); );
} }
+6 -19
View File
@@ -14,11 +14,7 @@ export const styles: StylesConfig = {
? "oklch(var(--p))" ? "oklch(var(--p))"
: "oklch(var(--nc))", : "oklch(var(--nc))",
}, },
transition: "all 100ms", transition: "all 50ms",
}),
menu: (styles) => ({
...styles,
zIndex: 10,
}), }),
control: (styles, state) => ({ control: (styles, state) => ({
...styles, ...styles,
@@ -54,28 +50,19 @@ export const styles: StylesConfig = {
multiValue: (styles) => { multiValue: (styles) => {
return { return {
...styles, ...styles,
backgroundColor: "oklch(var(--b2))", backgroundColor: "#0ea5e9",
color: "oklch(var(--bc))", color: "white",
display: "flex",
alignItems: "center",
gap: "0.1rem",
marginRight: "0.4rem",
}; };
}, },
multiValueLabel: (styles) => ({ multiValueLabel: (styles) => ({
...styles, ...styles,
color: "oklch(var(--bc))", color: "white",
}), }),
multiValueRemove: (styles) => ({ multiValueRemove: (styles) => ({
...styles, ...styles,
height: "1.2rem",
width: "1.2rem",
borderRadius: "100px",
transition: "all 100ms",
color: "oklch(var(--w))",
":hover": { ":hover": {
color: "red", color: "white",
backgroundColor: "oklch(var(--nc))", backgroundColor: "#38bdf8",
}, },
}), }),
menuPortal: (base) => ({ ...base, zIndex: 9999 }), menuPortal: (base) => ({ ...base, zIndex: 9999 }),
-54
View File
@@ -1,54 +0,0 @@
import { isPWA } from "@/lib/client/utils";
import React, { useState } from "react";
import { Trans } from "next-i18next";
type Props = {};
const InstallApp = (props: Props) => {
const [isOpen, setIsOpen] = useState(true);
return isOpen && !isPWA() ? (
<div className="fixed left-0 right-0 bottom-10 w-full px-5">
<div className="mx-auto w-fit p-2 flex justify-between gap-2 items-center border border-neutral-content rounded-xl bg-base-300 backdrop-blur-md bg-opacity-80 max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-8 h-8"
viewBox="0 0 50 50"
>
<path
fill="currentColor"
d="M30.3 13.7L25 8.4l-5.3 5.3l-1.4-1.4L25 5.6l6.7 6.7z"
/>
<path fill="currentColor" d="M24 7h2v21h-2z" />
<path
fill="currentColor"
d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3"
/>
</svg>
<p className="w-4/5 text-[0.92rem]">
<Trans
i18nKey="pwa_install_prompt"
components={[
<a
className="underline"
target="_blank"
href="https://docs.linkwarden.app/getting-started/pwa-installation"
key={0}
/>,
]}
/>
</p>
<button
onClick={() => setIsOpen(false)}
className="btn btn-ghost btn-square btn-sm"
>
<i className="bi-x text-xl"></i>
</button>
</div>
</div>
) : (
<></>
);
};
export default InstallApp;
-687
View File
@@ -1,687 +0,0 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import Link from "next/link";
import {
pdfAvailable,
readabilityAvailable,
monolithAvailable,
screenshotAvailable,
previewAvailable,
} from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import {
useGetLink,
useUpdateLink,
useUpdatePreview,
} from "@/hooks/store/links";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import CopyButton from "./CopyButton";
import { useRouter } from "next/router";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import Image from "next/image";
import clsx from "clsx";
import toast from "react-hot-toast";
import CollectionSelection from "./InputSelect/CollectionSelection";
import TagSelection from "./InputSelect/TagSelection";
import unescapeString from "@/lib/client/unescapeString";
import IconPopover from "./IconPopover";
import TextInput from "./TextInput";
import usePermissions from "@/hooks/usePermissions";
type Props = {
className?: string;
activeLink: LinkIncludingShortenedCollectionAndTags;
standalone?: boolean;
mode?: "view" | "edit";
setMode?: Function;
onUpdateArchive?: Function;
};
export default function LinkDetails({
className,
activeLink,
standalone,
mode = "view",
setMode,
onUpdateArchive,
}: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
useEffect(() => {
setLink(activeLink);
}, [activeLink]);
const permissions = usePermissions(link.collection.id as number);
const { t } = useTranslation();
const getLink = useGetLink();
const { data: user = {} } = useUser();
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === user.id) {
setCollectionOwner({
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
link &&
(collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending"
: true) &&
(collectionOwner.archiveAsMonolith === true
? link.monolith && link.monolith !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable &&
link.readable !== "pending"
);
};
const atLeastOneFormatAvailable = () => {
return (
screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link)
);
};
useEffect(() => {
(async () => {
await getLink.mutateAsync({
id: link.id as number,
});
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
await getLink.mutateAsync({
id: link.id as number,
});
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link.monolith]);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const updateLink = useUpdateLink();
const updatePreview = useUpdatePreview();
const submit = async (e?: any) => {
e?.preventDefault();
const { updatedAt: b, ...oldLink } = activeLink;
const { updatedAt: a, ...newLink } = link;
if (JSON.stringify(oldLink) === JSON.stringify(newLink)) {
return;
}
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setMode && setMode("view");
setLink(data);
}
},
});
};
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) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
const [iconPopover, setIconPopover] = useState(false);
return (
<div className={clsx(className)} data-vaul-no-drag>
<div
className={clsx(
standalone && "sm:border sm:border-neutral-content sm:rounded-2xl p-5"
)}
>
<div
className={clsx(
"overflow-hidden select-none relative group h-40 opacity-80",
standalone
? "sm:max-w-xl -mx-5 -mt-5 sm:rounded-t-2xl"
: "-mx-4 -mt-4"
)}
>
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className="object-cover scale-105 object-center h-full"
style={{
filter: "blur(1px)",
}}
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div className="bg-gray-50 duration-100 h-40"></div>
) : (
<div className="duration-100 h-40 skeleton rounded-none"></div>
)}
{!standalone &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute && (
<div className="absolute top-0 bottom-0 left-0 right-0 opacity-0 group-hover:opacity-100 duration-100 flex justify-end items-end">
<label className="btn btn-xs mb-2 mr-3 opacity-50 hover:opacity-100">
{t("upload_preview_image")}
<input
type="file"
accept="image/jpg, image/jpeg, image/png"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const load = toast.loading(t("updating"));
await updatePreview.mutateAsync(
{
linkId: link.id as number,
file,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setLink({ updatedAt: data.updatedAt, ...link });
}
},
}
);
}}
className="hidden"
/>
</label>
</div>
)}
</div>
{!standalone &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute ? (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<div className="tooltip tooltip-bottom" data-tip={t("change_icon")}>
<LinkIcon
link={link}
className="hover:bg-opacity-70 duration-100 cursor-pointer"
onClick={() => setIconPopover(true)}
/>
</div>
{iconPopover && (
<IconPopover
color={link.color || "#006796"}
setColor={(color: string) => setLink({ ...link, color })}
weight={(link.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setLink({ ...link, iconWeight })
}
iconName={link.icon as string}
setIconName={(icon: string) => setLink({ ...link, icon })}
reset={() =>
setLink({
...link,
color: "",
icon: "",
iconWeight: "",
})
}
className="top-12"
onClose={() => {
setIconPopover(false);
submit();
}}
/>
)}
</div>
) : (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<LinkIcon link={link} onClick={() => setIconPopover(true)} />
</div>
)}
<div className="max-w-xl sm:px-8 p-5 pb-8 pt-2">
{mode === "view" && (
<div className="text-xl mt-2 pr-7">
<p
className={clsx("relative w-fit", !link.name && "text-neutral")}
>
{unescapeString(link.name) || t("untitled")}
</p>
</div>
)}
{mode === "edit" && (
<>
<br />
<div>
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("name")}
</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
</>
)}
{link.url && mode === "view" ? (
<>
<br />
<p className="text-sm mb-2 text-neutral">{t("link")}</p>
<div className="relative">
<div className="rounded-md p-2 bg-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14">
<Link href={link.url} title={link.url} target="_blank">
{link.url}
</Link>
<div className="absolute right-0 px-2 bg-base-200">
<CopyButton text={link.url} />
</div>
</div>
</div>
</>
) : activeLink.url ? (
<>
<br />
<div>
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("link")}
</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
</>
) : undefined}
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("collection")}
</p>
{mode === "view" ? (
<div className="relative">
<Link
href={
isPublicRoute
? `/public/collections/${link.collection.id}`
: `/collections/${link.collection.id}`
}
className="rounded-md p-2 bg-base-200 border border-base-200 hide-scrollbar overflow-x-auto whitespace-nowrap flex justify-between items-center gap-2 pr-14"
>
<p>{link.collection.name}</p>
<div className="absolute right-0 px-2 bg-base-200">
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={30}
weight={
(link.collection.iconWeight ||
"regular") as IconWeight
}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: link.collection.color }}
></i>
)}
</div>
</Link>
</div>
) : (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.id
? { value: link.collection.id, label: link.collection.name }
: { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
/>
)}
</div>
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("tags")}
</p>
{mode === "view" ? (
<div className="flex gap-2 flex-wrap rounded-md p-2 bg-base-200 border border-base-200 w-full text-xs">
{link.tags && link.tags[0] ? (
link.tags.map((tag) =>
isPublicRoute ? (
<div
key={tag.id}
className="bg-base-200 p-1 hover:bg-neutral-content rounded-md duration-100"
>
{tag.name}
</div>
) : (
<Link
href={"/tags/" + tag.id}
key={tag.id}
className="bg-base-200 p-1 hover:bg-neutral-content btn btn-xs btn-ghost rounded-md"
>
{tag.name}
</Link>
)
)
) : (
<div className="text-neutral text-base">{t("no_tags")}</div>
)}
</div>
) : (
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
)}
</div>
<br />
<div className="relative">
<p className="text-sm mb-2 text-neutral relative w-fit flex justify-between">
{t("description")}
</p>
{mode === "view" ? (
<div className="rounded-md p-2 bg-base-200 hyphens-auto">
{link.description ? (
<p>{link.description}</p>
) : (
<p className="text-neutral">{t("no_description_provided")}</p>
)}
</div>
) : (
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 h-32 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
)}
</div>
{mode === "view" && (
<div>
<br />
<div className="flex gap-1 items-center mb-2">
<p
className="text-sm text-neutral"
title={t("available_formats")}
>
{link.url ? t("preserved_formats") : t("file")}
</p>
{onUpdateArchive &&
(permissions === true || permissions?.canUpdate) &&
!isPublicRoute && (
<div
className="tooltip tooltip-bottom"
data-tip={t("refresh_preserved_formats")}
>
<button
className="btn btn-xs btn-ghost btn-square text-neutral"
onClick={() => onUpdateArchive()}
>
<i className="bi-arrow-clockwise text-sm" />
</button>
</div>
)}
</div>
<div className={`flex flex-col rounded-md p-3 bg-base-200`}>
{monolithAvailable(link) ? (
<>
<PreservedFormatRow
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{screenshotAvailable(link) ? (
<>
<PreservedFormatRow
name={t("screenshot")}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{pdfAvailable(link) ? (
<>
<PreservedFormatRow
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
link={link}
downloadable={true}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{readabilityAvailable(link) ? (
<>
<PreservedFormatRow
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
link={link}
/>
<hr className="m-3 border-t border-neutral-content" />
</>
) : undefined}
{!isReady() && !atLeastOneFormatAvailable() ? (
<div
className={`w-full h-full flex flex-col justify-center p-10`}
>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-2xl">
{t("preservation_in_queue")}
</p>
<p className="text-center text-lg">
{t("check_back_later")}
</p>
</div>
) : link.url && !isReady() && atLeastOneFormatAvailable() ? (
<div
className={`w-full h-full flex flex-col justify-center p-5`}
>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={20}
/>
<p className="text-center">{t("there_are_more_formats")}</p>
<p className="text-center text-sm">
{t("check_back_later")}
</p>
</div>
) : undefined}
{link.url && (
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="text-neutral mx-auto duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">
{t("view_latest_snapshot")}
</p>
<i className="bi-box-arrow-up-right" />
</Link>
)}
</div>
</div>
)}
{mode === "view" ? (
<>
<br />
<p className="text-neutral text-xs text-center">
{t("saved")}{" "}
{new Date(link.createdAt || "").toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}{" "}
at{" "}
{new Date(link.createdAt || "").toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
})}
</p>
</>
) : (
<>
<br />
<div className="flex justify-end items-center">
<button
className={clsx(
"btn btn-accent text-white",
JSON.stringify(activeLink) === JSON.stringify(link)
? "btn-disabled"
: "dark:border-violet-400"
)}
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}
-217
View File
@@ -1,217 +0,0 @@
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import FilterSearchDropdown from "./FilterSearchDropdown";
import SortDropdown from "./SortDropdown";
import ViewDropdown from "./ViewDropdown";
import { TFunction } from "i18next";
import BulkDeleteLinksModal from "./ModalContent/BulkDeleteLinksModal";
import BulkEditLinksModal from "./ModalContent/BulkEditLinksModal";
import useCollectivePermissions from "@/hooks/useCollectivePermissions";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
import { Sort, ViewMode } from "@/types/global";
import { useBulkDeleteLinks, useLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
children: React.ReactNode;
t: TFunction<"translation", undefined>;
viewMode: ViewMode;
setViewMode: Dispatch<SetStateAction<ViewMode>>;
searchFilter?: {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
};
setSearchFilter?: (filter: {
name: boolean;
url: boolean;
description: boolean;
tags: boolean;
textContent: boolean;
}) => void;
sortBy: Sort;
setSortBy: Dispatch<SetStateAction<Sort>>;
editMode?: boolean;
setEditMode?: (mode: boolean) => void;
};
const LinkListOptions = ({
children,
t,
viewMode,
setViewMode,
searchFilter,
setSearchFilter,
sortBy,
setSortBy,
editMode,
setEditMode,
}: Props) => {
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const { links } = useLinks();
const router = useRouter();
const [bulkDeleteLinksModal, setBulkDeleteLinksModal] = useState(false);
const [bulkEditLinksModal, setBulkEditLinksModal] = useState(false);
useEffect(() => {
if (editMode && setEditMode) return setEditMode(false);
}, [router]);
const collectivePermissions = useCollectivePermissions(
selectedLinks.map((link) => link.collectionId as number)
);
const handleSelectAll = () => {
if (selectedLinks.length === links.length) {
setSelectedLinks([]);
} else {
setSelectedLinks(links.map((link) => link));
}
};
const bulkDeleteLinks = async () => {
const load = toast.loading(t("deleting"));
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
toast.success(t("deleted"));
}
},
}
);
};
return (
<>
<div className="flex justify-between items-center">
{children}
<div className="flex gap-3 items-center justify-end">
<div className="flex gap-2 items-center mt-2">
{links &&
links.length > 0 &&
editMode !== undefined &&
setEditMode && (
<div
role="button"
onClick={() => {
setEditMode(!editMode);
setSelectedLinks([]);
}}
className={`btn btn-square btn-sm btn-ghost ${
editMode
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-pencil-fill text-neutral text-xl"></i>
</div>
)}
{searchFilter && setSearchFilter && (
<FilterSearchDropdown
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
)}
<SortDropdown
sortBy={sortBy}
setSort={(value) => {
setSortBy(value);
}}
t={t}
/>
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div>
</div>
</div>
{links && editMode && links.length > 0 && (
<div className="w-full flex justify-between items-center min-h-[32px]">
<div className="flex gap-3 ml-3">
<input
type="checkbox"
className="checkbox checkbox-primary"
onChange={() => handleSelectAll()}
checked={
selectedLinks.length === links.length && links.length > 0
}
/>
{selectedLinks.length > 0 ? (
<span>
{selectedLinks.length === 1
? t("link_selected")
: t("links_selected", { count: selectedLinks.length })}
</span>
) : (
<span>{t("nothing_selected")}</span>
)}
</div>
<div className="flex gap-3">
<button
onClick={() => setBulkEditLinksModal(true)}
className="btn btn-sm btn-accent text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canUpdate
)
}
>
{t("edit")}
</button>
<button
onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey ? bulkDeleteLinks() : setBulkDeleteLinksModal(true);
}}
className="btn btn-sm bg-red-500 hover:bg-red-400 text-white w-fit ml-auto"
disabled={
selectedLinks.length === 0 ||
!(
collectivePermissions === true ||
collectivePermissions?.canDelete
)
}
>
{t("delete")}
</button>
</div>
</div>
)}
{bulkDeleteLinksModal && (
<BulkDeleteLinksModal
onClose={() => {
setBulkDeleteLinksModal(false);
}}
/>
)}
{bulkEditLinksModal && (
<BulkEditLinksModal
onClose={() => {
setBulkEditLinksModal(false);
}}
/>
)}
</>
);
};
export default LinkListOptions;
+16
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
}
+210
View File
@@ -0,0 +1,210 @@
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"
>
<Link
href={link.url || ""}
target="_blank"
className="rounded-2xl cursor-pointer"
>
<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">
<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>
</Link>
{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>
);
}
@@ -4,189 +4,172 @@ import {
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal"; import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import { dropdownTriggerer } from "@/lib/client/utils"; import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import { useTranslation } from "next-i18next"; import useLinkStore from "@/store/links";
import { useDeleteLink, useGetLink } from "@/hooks/store/links"; import { toast } from "react-hot-toast";
import toast from "react-hot-toast"; import useAccountStore from "@/store/account";
import LinkModal from "@/components/ModalContent/LinkModal";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
btnStyle?: string; position?: string;
toggleShowInfo?: () => void;
linkInfo?: boolean;
}; };
export default function LinkActions({ link, btnStyle }: Props) { export default function LinkActions({
const { t } = useTranslation(); link,
toggleShowInfo,
position,
linkInfo,
}: Props) {
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
const getLink = useGetLink();
const pinLink = usePinLink();
const [editLinkModal, setEditLinkModal] = useState(false); const [editLinkModal, setEditLinkModal] = useState(false);
const [linkModal, setLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false); const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const deleteLink = useDeleteLink(); const { account } = useAccountStore();
const updateArchive = async () => { const { removeLink, updateLink } = useLinkStore();
const load = toast.loading(t("sending_request"));
const response = await fetch(`/api/v1/links/${link?.id}/archive`, { const pinLink = async () => {
method: "PUT", const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
const load = toast.loading("Applying...");
const response = await updateLink({
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
}); });
const data = await response.json();
toast.dismiss(load); toast.dismiss(load);
if (response.ok) { response.ok &&
await getLink.mutateAsync({ id: link.id as number }); toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
toast.success(t("link_being_archived"));
} else toast.error(data.response);
}; };
const router = useRouter(); const deleteLink = async () => {
const load = toast.loading("Deleting...");
const isPublicRoute = router.pathname.startsWith("/public") ? true : false; const response = await removeLink(link.id as number);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
};
return ( return (
<> <>
{isPublicRoute ? ( <div
className={`dropdown dropdown-left absolute ${
position || "top-3 right-3"
} z-20`}
>
<div <div
className="absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100" tabIndex={0}
onMouseDown={dropdownTriggerer} role="button"
onClick={() => setLinkModal(true)} className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}> <i title="More" className="bi-three-dots text-xl" />
<i title="More" className="bi-info-circle text-xl" />
</div>
</div> </div>
) : ( <ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
<div {permissions === true ? (
className={`dropdown dropdown-end absolute top-3 right-3 group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100 z-20`}
>
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}
>
<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 mt-1"
}
>
<li> <li>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
pinLink(link); pinLink();
}} }}
className="whitespace-nowrap"
> >
{link?.pinnedBy && link.pinnedBy[0] {link?.pinnedBy && link.pinnedBy[0]
? t("unpin") ? "Unpin"
: t("pin_to_dashboard")} : "Pin to Dashboard"}
</div> </div>
</li> </li>
) : undefined}
{linkInfo !== undefined && toggleShowInfo ? (
<li> <li>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
setLinkModal(true); toggleShowInfo();
}} }}
className="whitespace-nowrap"
> >
{t("show_link_details")} {!linkInfo ? "Show" : "Hide"} Link Details
</div> </div>
</li> </li>
{(permissions === true || permissions?.canUpdate) && ( ) : undefined}
<li> {permissions === true || permissions?.canUpdate ? (
<div <li>
role="button" <div
tabIndex={0} role="button"
onClick={() => { tabIndex={0}
(document?.activeElement as HTMLElement)?.blur(); onClick={() => {
setEditLinkModal(true); (document?.activeElement as HTMLElement)?.blur();
}} setEditLinkModal(true);
className="whitespace-nowrap" }}
> >
{t("edit_link")} Edit Link
</div> </div>
</li> </li>
)} ) : undefined}
{(permissions === true || permissions?.canDelete) && ( <li>
<li> <div
<div role="button"
role="button" tabIndex={0}
tabIndex={0} onClick={() => {
onClick={async (e) => { (document?.activeElement as HTMLElement)?.blur();
(document?.activeElement as HTMLElement)?.blur(); setPreservedFormatsModal(true);
console.log(e.shiftKey); }}
e.shiftKey >
? (async () => { Preserved Formats
const load = toast.loading(t("deleting")); </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>
await deleteLink.mutateAsync(link.id as number, { {editLinkModal ? (
onSettled: (data, error) => { <EditLinkModal
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
})()
: setDeleteLinkModal(true);
}}
className="whitespace-nowrap"
>
{t("delete")}
</div>
</li>
)}
</ul>
</div>
)}
{editLinkModal && (
<LinkModal
onClose={() => setEditLinkModal(false)} onClose={() => setEditLinkModal(false)}
onPin={() => pinLink(link)} activeLink={link}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link}
activeMode="edit"
/> />
)} ) : undefined}
{deleteLinkModal && ( {deleteLinkModal ? (
<DeleteLinkModal <DeleteLinkModal
onClose={() => setDeleteLinkModal(false)} onClose={() => setDeleteLinkModal(false)}
activeLink={link} activeLink={link}
/> />
)} ) : undefined}
{linkModal && ( {preservedFormatsModal ? (
<LinkModal <PreservedFormatsModal
onClose={() => setLinkModal(false)} onClose={() => setPreservedFormatsModal(false)}
onPin={() => pinLink(link)} activeLink={link}
onUpdateArchive={updateArchive}
onDelete={() => setDeleteLinkModal(true)}
link={link}
/> />
)} ) : undefined}
{/* {expandedLink ? (
<ExpandedLink onClose={() => setExpandedLink(false)} link={link} />
) : undefined} */}
</> </>
); );
} }
@@ -1,241 +0,0 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
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 LinkIcon from "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links";
import { useRouter } from "next/router";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
columns: number;
className?: string;
editMode?: boolean;
};
export default function LinkCard({ link, columns, editMode }: Props) {
const { t } = useTranslation();
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const {
data: { data: links = [] },
} = useLinks();
const getLink = useGetLink();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
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);
const permissions = usePermissions(collection?.id as number);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync({
id: link.id as number,
isPublicRoute: isPublicRoute,
});
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return (
<div
ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative group`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="rounded-2xl cursor-pointer h-full flex flex-col justify-between"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
{show.image && (
<div>
<div
className={`relative rounded-t-2xl ${imageHeightClass} overflow-hidden`}
>
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className={`rounded-t-2xl select-none object-cover z-10 ${imageHeightClass} w-full shadow opacity-80 scale-105`}
style={show.icon ? { filter: "blur(1px)" } : undefined}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? (
<div
className={`bg-gray-50 ${imageHeightClass} bg-opacity-80`}
></div>
) : (
<div
className={`${imageHeightClass} bg-opacity-80 skeleton rounded-none`}
></div>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center rounded-md">
<LinkIcon link={link} />
</div>
)}
</div>
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
</div>
)}
<div className="flex flex-col justify-between h-full min-h-24">
<div className="p-3 flex flex-col gap-2">
{show.name && (
<p className="truncate w-full text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
</div>
{(show.collection || show.date) && (
<div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex justify-between items-center text-xs text-neutral px-3 pb-1 gap-2">
{show.collection && !isPublicRoute && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div>
</div>
{/* Overlay on hover */}
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-2xl duration-100"></div>
<LinkActions link={link} collection={collection} />
{!isPublicRoute && <LinkPin link={link} />}
</div>
);
}
@@ -1,10 +1,7 @@
import Icon from "@/components/Icon";
import { import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import { IconWeight } from "@phosphor-icons/react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
@@ -17,33 +14,20 @@ export default function LinkCollection({
}) { }) {
const router = useRouter(); const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false; return (
<div
return !isPublicRoute && collection?.name ? ( onClick={(e) => {
<> e.preventDefault();
<Link router.push(`/collections/${link.collection.id}`);
href={`/collections/${link.collection.id}`} }}
onClick={(e) => { className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
e.stopPropagation(); title={collection?.name}
}} >
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none" <i
title={collection?.name} className="bi-folder-fill text-lg drop-shadow"
> style={{ color: collection?.color }}
{link.collection.icon ? ( ></i>
<Icon <p className="truncate capitalize">{collection?.name}</p>
icon={link.collection.icon} </div>
size={20} );
weight={(link.collection.iconWeight || "regular") as IconWeight}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-lg"
style={{ color: link.collection.color }}
></i>
)}
<p className="truncate capitalize">{collection?.name}</p>
</Link>
</>
) : null;
} }
@@ -6,16 +6,17 @@ export default function LinkDate({
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
}) { }) {
const formattedDate = new Date( const formattedDate = new Date(link.createdAt as string).toLocaleString(
(link.importDate || link.createdAt) as string "en-US",
).toLocaleString("en-US", { {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
}); }
);
return ( return (
<div className="flex items-center gap-1 text-neutral min-w-fit"> <div className="flex items-center gap-1 text-neutral">
<i className="bi-calendar3 text-lg"></i> <i className="bi-calendar3 text-lg"></i>
<p>{formattedDate}</p> <p>{formattedDate}</p>
</div> </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>
);
}
@@ -1,99 +1,48 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Image from "next/image"; import Image from "next/image";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import React, { useState } from "react"; import React from "react";
import Icon from "@/components/Icon";
import { IconWeight } from "@phosphor-icons/react";
import clsx from "clsx";
export default function LinkIcon({ export default function LinkIcon({
link, link,
className, width,
hideBackground,
onClick,
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
className?: string; width?: string;
hideBackground?: boolean;
onClick?: Function;
}) { }) {
let iconClasses: string = clsx(
"rounded flex item-center justify-center shadow select-none z-10 w-12 h-12",
!hideBackground && "rounded-md bg-white backdrop-blur-lg bg-opacity-50 p-1",
className
);
const url = const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
const [faviconLoaded, setFaviconLoaded] = useState(false); 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 ( return (
<div onClick={() => onClick && onClick()}> <>
{link.icon ? ( {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}> <div className={iconClasses}>
<Icon <i className="bi-link-45deg text-4xl text-black"></i>
icon={link.icon}
size={30}
weight={(link.iconWeight || "regular") as IconWeight}
color={link.color || "#006796"}
className="m-auto"
/>
</div> </div>
) : link.type === "url" && url ? (
<>
<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={clsx(
iconClasses,
faviconLoaded ? "" : "absolute opacity-0"
)}
draggable="false"
onLoadingComplete={() => setFaviconLoaded(true)}
onError={() => setFaviconLoaded(false)}
/>
{!faviconLoaded && (
<LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-link-45deg"
/>
)}
</>
) : link.type === "pdf" ? ( ) : link.type === "pdf" ? (
<LinkPlaceholderIcon <i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
iconClasses={iconClasses}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? ( ) : link.type === "image" ? (
<LinkPlaceholderIcon <i className={`bi-file-earmark-image ${iconClasses}`}></i>
iconClasses={iconClasses} ) : undefined}
icon="bi-file-earmark-image" </>
/>
) : // : link.type === "monolith" ? (
// <LinkPlaceholderIcon
// iconClasses={iconClasses + dimension}
// size={size}
// icon="bi-filetype-html"
// />
// )
undefined}
</div>
); );
} }
const LinkPlaceholderIcon = ({
iconClasses,
icon,
}: {
iconClasses: string;
icon: string;
}) => {
return (
<div className={clsx(iconClasses, "aspect-square text-4xl text-[#006796]")}>
<i className={`${icon} m-auto`}></i>
</div>
);
};
@@ -1,148 +0,0 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
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 { isPWA } from "@/lib/client/utils";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useLinks } from "@/hooks/store/links";
import useLocalSettingsStore from "@/store/localSettings";
import LinkPin from "./LinkPin";
import { useRouter } from "next/router";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
editMode?: boolean;
};
export default function LinkCardCompact({ link, editMode }: Props) {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
const linkIndex = selectedLinks.findIndex(
(selectedLink) => selectedLink.id === link.id
);
if (linkIndex !== -1) {
const updatedLinks = [...selectedLinks];
updatedLinks.splice(linkIndex, 1);
setSelectedLinks(updatedLinks);
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
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 permissions = usePermissions(collection?.id as number);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border border-primary bg-base-300"
: "border-transparent";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
return (
<>
<div
className={`${selectedStyle} rounded-md border relative group items-center flex ${
!isPWA() ? "hover:bg-base-300 px-2 py-1" : "py-1"
} duration-200 w-full`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="flex items-center cursor-pointer w-full min-h-12"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
{show.icon && (
<div className="shrink-0">
<LinkIcon link={link} hideBackground />
</div>
)}
<div className="w-[calc(100%-56px)] ml-2">
{show.name && (
<p className="line-clamp-1 mr-8 text-primary select-none">
{unescapeString(link.name)}
</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-x-3 text-neutral flex-wrap">
{show.link && <LinkTypeBadge link={link} />}
{show.collection && (
<LinkCollection link={link} collection={collection} />
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
</div>
</div>
{!isPublic && <LinkPin link={link} btnStyle="btn-ghost" />}
<LinkActions link={link} collection={collection} btnStyle="btn-ghost" />
</div>
<div className="last:hidden rounded-none my-0 mx-1 border-t border-base-300 h-[1px]"></div>
</>
);
}
@@ -1,252 +0,0 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, useMemo, useRef, useState } from "react";
import useLinkStore from "@/store/links";
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 "./LinkIcon";
import useOnScreen from "@/hooks/useOnScreen";
import { generateLinkHref } from "@/lib/client/generateLinkHref";
import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkTypeBadge";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useLinks } from "@/hooks/store/links";
import useLocalSettingsStore from "@/store/localSettings";
import clsx from "clsx";
import LinkPin from "./LinkPin";
import { useRouter } from "next/router";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
columns: number;
editMode?: boolean;
};
export default function LinkMasonry({ link, editMode, columns }: Props) {
const { t } = useTranslation();
const heightMap = {
1: "h-44",
2: "h-40",
3: "h-36",
4: "h-32",
5: "h-28",
6: "h-24",
7: "h-20",
8: "h-20",
};
const imageHeightClass = useMemo(
() => (columns ? heightMap[columns as keyof typeof heightMap] : "h-40"),
[columns]
);
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
const {
settings: { show },
} = useLocalSettingsStore();
const { links } = useLinks();
const getLink = useGetLink();
useEffect(() => {
if (!editMode) {
setSelectedLinks([]);
}
}, [editMode]);
const handleCheckboxClick = (
link: LinkIncludingShortenedCollectionAndTags
) => {
if (selectedLinks.includes(link)) {
setSelectedLinks(selectedLinks.filter((e) => e !== link));
} else {
setSelectedLinks([...selectedLinks, link]);
}
};
let shortendURL;
try {
if (link.url) {
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);
const permissions = usePermissions(collection?.id as number);
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync({ id: link.id as number });
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const selectedStyle = selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)
? "border-primary bg-base-300"
: "border-neutral-content";
const selectable =
editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete);
return (
<div
ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative group`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
<div
className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
{show.image && previewAvailable(link) && (
<div>
<div className="relative rounded-t-2xl overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
className={`rounded-t-2xl select-none object-cover z-10 ${imageHeightClass} w-full shadow opacity-80 scale-105`}
style={show.icon ? { filter: "blur(1px)" } : undefined}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.preview === "unavailable" ? null : (
<div
className={`duration-100 ${imageHeightClass} bg-opacity-80 skeleton rounded-none`}
></div>
)}
{show.icon && (
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center rounded-md">
<LinkIcon link={link} />
</div>
)}
</div>
<hr className="divider my-0 border-t border-neutral-content h-[1px]" />
</div>
)}
<div className="p-3 flex flex-col gap-2 h-full min-h-14">
{show.name && (
<p className="hyphens-auto w-full text-primary text-sm">
{unescapeString(link.name)}
</p>
)}
{show.link && <LinkTypeBadge link={link} />}
{show.description && link.description && (
<p className={clsx("hyphens-auto text-sm w-full")}>
{unescapeString(link.description)}
</p>
)}
{show.tags && link.tags && link.tags[0] && (
<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>
{(show.collection || show.date) && (
<div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex flex-wrap justify-between items-center text-xs text-neutral px-3 pb-1 w-full gap-x-2">
{!isPublic && show.collection && (
<div className="cursor-pointer truncate">
<LinkCollection link={link} collection={collection} />
</div>
)}
{show.date && <LinkDate link={link} />}
</div>
</div>
)}
</div>
{/* Overlay on hover */}
<div className="absolute pointer-events-none top-0 left-0 right-0 bottom-0 bg-base-100 bg-opacity-0 group-hover:bg-opacity-20 group-focus-within:opacity-20 rounded-2xl duration-100"></div>
<LinkActions link={link} collection={collection} />
{!isPublic && <LinkPin link={link} />}
</div>
);
}
@@ -1,34 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
btnStyle?: string;
};
export default function LinkPin({ link, btnStyle }: Props) {
const pinLink = usePinLink();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
return (
<div
className="absolute top-3 right-[3.25rem] group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
onClick={() => pinLink(link)}
>
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
<i
title="Pin"
className={clsx(
"text-xl",
isAlreadyPinned ? "bi-pin-fill" : "bi-pin"
)}
/>
</div>
</div>
);
}
@@ -1,36 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
let shortendURL;
if (link.type === "url" && link.url) {
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
}
}
return link.url && shortendURL ? (
<Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
onClick={(e) => {
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral hover:opacity-70 duration-100 max-w-full w-fit"
>
<i className="bi-link-45deg text-lg leading-none"></i>
<p className="text-xs truncate">{shortendURL}</p>
</Link>
) : (
<div className="badge badge-primary badge-sm select-none">{link.type}</div>
);
}
+111
View File
@@ -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} />
&middot;
{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>
);
}
+153
View File
@@ -0,0 +1,153 @@
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`}
>
<Link
href={link.url || ""}
target="_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} />
&middot;
</>
) : 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">&middot;</span>
<LinkDate link={link} />
</div>
</div>
</Link>
<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>
</>
);
}
-336
View File
@@ -1,336 +0,0 @@
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
import {
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@/types/global";
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
import useLocalSettingsStore from "@/store/localSettings";
export function CardView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
return (
<div className={`${gridColClass} grid gap-5 pb-5`}>
{links?.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
editMode={editMode}
columns={columnCount}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</div>
);
}
export function MasonryView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={
settings.columns === 0 ? breakpointColumnsObj : columnCount
}
columnClassName="flex flex-col gap-5 !w-full"
className={`${gridColClass} grid gap-5 pb-5`}
>
{links?.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
editMode={editMode}
columns={columnCount}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
className="flex flex-col gap-4"
ref={e === 1 ? placeHolderRef : undefined}
key={i}
>
<div className="skeleton h-40 w-full"></div>
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
);
})}
</Masonry>
);
}
export function ListView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
return (
<div className="flex flex-col">
{links?.map((e, i) => {
return <LinkList key={i} link={e} count={i} editMode={editMode} />;
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
ref={e === 1 ? placeHolderRef : undefined}
key={i}
className="flex gap-2 py-2 px-1"
>
<div className="skeleton h-12 w-12"></div>
<div className="flex flex-col gap-3 w-full">
<div className="skeleton h-2 w-2/3"></div>
<div className="skeleton h-2 w-full"></div>
<div className="skeleton h-2 w-1/3"></div>
</div>
</div>
);
})}
</div>
);
}
export default function Links({
layout,
links,
editMode,
placeholderCount,
useData,
}: {
layout: ViewMode;
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
placeholderCount?: number;
useData?: any;
}) {
const { ref, inView } = useInView();
useEffect(() => {
if (inView && useData?.fetchNextPage && useData?.hasNextPage) {
useData.fetchNextPage();
}
}, [useData, inView]);
if (layout === ViewMode.List) {
return (
<ListView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
} else if (layout === ViewMode.Masonry) {
return (
<MasonryView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
} else {
// Default to card view
return (
<CardView
links={links}
editMode={editMode}
isLoading={useData?.isLoading}
placeholders={placeholderCountToArray(placeholderCount)}
hasNextPage={useData?.hasNextPage}
placeHolderRef={ref}
/>
);
}
}
const placeholderCountToArray = (num?: number) =>
num ? Array.from({ length: num }, (_, i) => i + 1) : [];
+7
View File
@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div>
<p>Loading...</p>
</div>
);
}
-99
View File
@@ -1,99 +0,0 @@
import { dropdownTriggerer, isIphone, isPWA } from "@/lib/client/utils";
import React from "react";
import { useState } from "react";
import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import UploadFileModal from "./ModalContent/UploadFileModal";
import MobileNavigationButton from "./MobileNavigationButton";
import { useTranslation } from "next-i18next";
type Props = {};
export default function MobileNavigation({}: Props) {
const { t } = useTranslation();
const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
return (
<>
<div
className={`fixed bottom-0 left-0 right-0 z-30 duration-200 sm:hidden`}
>
<div
className={`w-full flex bg-base-100 ${
isIphone() && isPWA() ? "pb-5" : ""
} border-solid border-t-neutral-content border-t`}
>
<MobileNavigationButton href={`/dashboard`} icon={"bi-house"} />
<MobileNavigationButton
href={`/links/pinned`}
icon={"bi-pin-angle"}
/>
<div className="dropdown dropdown-top -mt-4">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className={`flex items-center btn btn-accent dark:border-violet-400 text-white btn-circle w-20 h-20 px-2 relative`}
>
<span>
<i className="bi-plus text-5xl pointer-events-none"></i>
</span>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mb-1 -ml-12">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_link")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("upload_file")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("new_collection")}
</div>
</li>
</ul>
</div>
<MobileNavigationButton href={`/links`} icon={"bi-link-45deg"} />
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}
{uploadFileModal && (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
)}
</>
);
}
-45
View File
@@ -1,45 +0,0 @@
import { isPWA } from "@/lib/client/utils";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
export default function MobileNavigationButton({
href,
icon,
}: {
href: string;
icon: string;
}) {
const router = useRouter();
const [active, setActive] = useState(false);
useEffect(() => {
setActive(href === router.asPath);
}, [router]);
return (
<Link
href={href}
className="w-full active:scale-[80%] duration-200 select-none"
draggable="false"
style={{ WebkitTouchCallout: "none" }}
onContextMenu={(e) => {
if (isPWA()) {
e.preventDefault();
e.stopPropagation();
return false;
} else return null;
}}
>
<div
className={`py-2 cursor-pointer gap-2 w-full rounded-full capitalize flex items-center justify-center`}
>
<i
className={`${icon} text-primary text-3xl drop-shadow duration-200 rounded-full w-14 h-14 text-center pt-[0.65rem] ${
active || false ? "bg-primary/20" : ""
}`}
></i>
</div>
</Link>
);
}
+22 -74
View File
@@ -1,90 +1,38 @@
import React, { MouseEventHandler, ReactNode, useEffect } from "react"; import React, { MouseEventHandler, ReactNode, useEffect } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
import { Drawer } from "vaul";
type Props = { type Props = {
toggleModal: Function; toggleModal: Function;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
dismissible?: boolean;
}; };
export default function Modal({ export default function Modal({ toggleModal, className, children }: Props) {
toggleModal,
className,
children,
dismissible = true,
}: Props) {
const [drawerIsOpen, setDrawerIsOpen] = React.useState(true);
useEffect(() => { useEffect(() => {
if (window.innerWidth >= 640) { document.body.style.overflow = "hidden";
document.body.style.overflow = "hidden"; return () => {
document.body.style.position = "relative"; document.body.style.overflow = "auto";
return () => { };
document.body.style.overflow = "auto"; });
document.body.style.position = "";
};
}
}, []);
if (window.innerWidth < 640) { return (
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">
<Drawer.Root <ClickAwayHandler
open={drawerIsOpen} onClickOutside={toggleModal}
onClose={() => dismissible && setDrawerIsOpen(false)} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
onAnimationEnd={(isOpen) => !isOpen && toggleModal()} className || ""
dismissible={dismissible} }`}
> >
<Drawer.Portal> <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">
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
<Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30">
<div
className="p-4 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"
data-testid="mobile-modal-container"
>
<div
className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5"
data-testid="mobile-modal-slider"
/>
{children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
);
} else {
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"
data-testid="modal-outer"
>
<ClickAwayHandler
onClickOutside={() => dismissible && toggleModal()}
className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
>
<div <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" onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
data-testid="modal-container" className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
> >
{dismissible && ( <i className="bi-x text-neutral text-2xl"></i>
<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"
data-testid="close-modal-button"
></i>
</div>
)}
{children}
</div> </div>
</ClickAwayHandler> {children}
</div> </div>
); </ClickAwayHandler>
} </div>
);
} }
@@ -1,73 +0,0 @@
import React from "react";
import useLinkStore from "@/store/links";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useBulkDeleteLinks } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
};
export default function BulkDeleteLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const deleteLinksById = useBulkDeleteLinks();
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
await deleteLinksById.mutateAsync(
selectedLinks.map((link) => link.id as number),
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("deleted"));
}
},
}
);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{selectedLinks.length === 1
? t("delete_link")
: t("delete_links", { count: selectedLinks.length })}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
{selectedLinks.length === 1
? t("link_deletion_confirmation_message")
: t("links_deletion_confirmation_message", {
count: selectedLinks.length,
})}
</p>
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>{t("warning_irreversible")}</span>
</div>
<p>{t("shift_key_tip")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<i className="bi-trash text-xl" />
{t("delete")}
</Button>
</div>
</Modal>
);
}
@@ -1,112 +0,0 @@
import React, { useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useBulkEditLinks } from "@/hooks/store/links";
type Props = {
onClose: Function;
};
export default function BulkEditLinksModal({ onClose }: Props) {
const { t } = useTranslation();
const { selectedLinks, setSelectedLinks } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [removePreviousTags, setRemovePreviousTags] = useState(false);
const [updatedValues, setUpdatedValues] = useState<
Pick<LinkIncludingShortenedCollectionAndTags, "tags" | "collectionId">
>({ tags: [] });
const updateLinks = useBulkEditLinks();
const setCollection = (e: any) => {
const collectionId = e?.value || null;
setUpdatedValues((prevValues) => ({ ...prevValues, collectionId }));
};
const setTags = (e: any) => {
const tags = e.map((tag: any) => ({ name: tag.label }));
setUpdatedValues((prevValues) => ({ ...prevValues, tags }));
};
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("updating"));
await updateLinks.mutateAsync(
{
links: selectedLinks,
newData: updatedValues,
removePreviousTags,
},
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("updated"));
}
},
}
);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{selectedLinks.length === 1
? t("edit_link")
: t("edit_links", { count: selectedLinks.length })}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("move_to_collection")}</p>
<CollectionSelection
showDefaultValue={false}
onChange={setCollection}
creatable={false}
/>
</div>
<div>
<p className="mb-2">{t("add_tags")}</p>
<TagSelection onChange={setTags} />
</div>
</div>
<div className="sm:ml-auto w-1/2 p-3">
<label className="flex items-center gap-2 ">
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={removePreviousTags}
onChange={(e) => setRemovePreviousTags(e.target.checked)}
/>
{t("remove_previous_tags")}
</label>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</Modal>
);
}
@@ -1,13 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal"; import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -18,51 +16,51 @@ export default function DeleteCollectionModal({
onClose, onClose,
activeCollection, activeCollection,
}: Props) { }: Props) {
const { t } = useTranslation();
const [collection, setCollection] = const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection); useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number);
useEffect(() => { useEffect(() => {
setCollection(activeCollection); setCollection(activeCollection);
}, []); }, []);
const deleteCollection = useDeleteCollection(); 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 () => { const submit = async () => {
if (permissions === true && collection.name !== inputField) return; if (permissions === true) if (collection.name !== inputField) return null;
if (!submitLoader) { if (!submitLoader) {
setSubmitLoader(true); setSubmitLoader(true);
if (!collection) return null; if (!collection) return null;
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("deleting_collection")); const load = toast.loading("Deleting...");
deleteCollection.mutateAsync(collection.id as number, { let response;
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) { response = await removeCollection(collection.id as any);
toast.error(error.message);
} else { toast.dismiss(load);
onClose();
toast.success(t("deleted")); if (response.ok) {
router.push("/collections"); toast.success(`Deleted.`);
} onClose();
}, router.push("/collections");
}); } else toast.error(response.data as string);
setSubmitLoader(false);
} }
}; };
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500"> <p className="text-xl font-thin text-red-500">
{permissions === true ? t("delete_collection") : t("leave_collection")} {permissions === true ? "Delete" : "Leave"} Collection
</p> </p>
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
@@ -70,37 +68,48 @@ export default function DeleteCollectionModal({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{permissions === true ? ( {permissions === true ? (
<> <>
<p>{t("confirm_deletion_prompt", { name: collection.name })}</p> <div className="flex flex-col gap-3">
<TextInput <p>
value={inputField} To confirm, type &quot;
onChange={(e) => setInputField(e.target.value)} <span className="font-bold">{collection.name}</span>
placeholder={t("type_name_placeholder", { &quot; in the box below:
name: collection.name, </p>
})}
className="w-3/4 mx-auto" <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"> <div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl"></i> <i className="bi-exclamation-triangle text-xl"></i>
<span> <span>
<b>{t("warning")}: </b> <b>Warning:</b> Deleting this collection will permanently erase
{t("deletion_warning")} all its contents, and it will become inaccessible to everyone,
including members with previous access.
</span> </span>
</div> </div>
</> </>
) : ( ) : (
<p>{t("leave_prompt")}</p> <p>Click the button below to leave the current collection.</p>
)} )}
<Button <button
disabled={permissions === true && inputField !== collection.name} 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} onClick={submit}
intent="destructive"
className="ml-auto"
> >
<i className="bi-trash text-xl"></i> <i className="bi-trash text-xl"></i>
{permissions === true ? t("delete") : t("leave")} {permissions === true ? "Delete" : "Leave"} Collection
</Button> </button>
</div> </div>
</Modal> </Modal>
); );
+30 -29
View File
@@ -1,11 +1,9 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -13,59 +11,62 @@ type Props = {
}; };
export default function DeleteLinkModal({ onClose, activeLink }: Props) { export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink); useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const deleteLink = useDeleteLink(); const { removeLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
setLink(activeLink); setLink(activeLink);
}, []); }, []);
const submit = async () => { const deleteLink = async () => {
const load = toast.loading(t("deleting")); const load = toast.loading("Deleting...");
await deleteLink.mutateAsync(link.id as number, { const response = await removeLink(link.id as number);
onSettled: (data, error) => {
toast.dismiss(load);
if (error) { toast.dismiss(load);
toast.error(error.message);
} else { response.ok && toast.success(`Link Deleted.`);
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard"); if (router.pathname.startsWith("/links/[id]")) {
} router.push("/dashboard");
toast.success(t("deleted")); }
onClose();
} onClose();
},
});
}; };
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("delete_link")}</p> <p className="text-xl font-thin text-red-500">Delete Link</p>
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p>{t("link_deletion_confirmation_message")}</p> <p>Are you sure you want to delete this Link?</p>
<div role="alert" className="alert alert-warning"> <div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" /> <i className="bi-exclamation-triangle text-xl" />
<span> <span>
<b>{t("warning")}:</b> {t("irreversible_warning")} <b>Warning:</b> This action is irreversible!
</span> </span>
</div> </div>
<p>{t("shift_key_tip")}</p> <p>
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
&apos;Delete&apos; to bypass this confirmation in the future.
</p>
<Button className="ml-auto" intent="destructive" onClick={submit}> <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" /> <i className="bi-trash text-xl" />
{t("delete")} Delete
</Button> </button>
</div> </div>
</Modal> </Modal>
); );
@@ -1,65 +0,0 @@
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteUser } from "@/hooks/store/admin/users";
import { useState } from "react";
import { useSession } from "next-auth/react";
type Props = {
onClose: Function;
userId: number;
};
export default function DeleteUserModal({ onClose, userId }: Props) {
const { t } = useTranslation();
const [submitLoader, setSubmitLoader] = useState(false);
const deleteUser = useDeleteUser();
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
await deleteUser.mutateAsync(userId, {
onSuccess: () => {
onClose();
},
onSettled: (data, error) => {
setSubmitLoader(false);
},
});
}
};
const { data } = useSession();
const isAdmin = data?.user?.id === Number(process.env.NEXT_PUBLIC_ADMIN);
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{isAdmin ? t("delete_user") : t("remove_user")}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("confirm_user_deletion")}</p>
<p>{t("confirm_user_removal_desc")}</p>
{isAdmin && (
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
</span>
</div>
)}
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{isAdmin ? t("delete_confirmation") : t("confirm")}
</Button>
</div>
</Modal>
);
}
+53 -53
View File
@@ -1,12 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import TextInput from "@/components/TextInput"; 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 { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -17,12 +15,11 @@ export default function EditCollectionModal({
onClose, onClose,
activeCollection, activeCollection,
}: Props) { }: Props) {
const { t } = useTranslation();
const [collection, setCollection] = const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection); useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const updateCollection = useUpdateCollection(); const { updateCollection } = useCollectionStore();
const submit = async () => { const submit = async () => {
if (!submitLoader) { if (!submitLoader) {
@@ -31,76 +28,79 @@ export default function EditCollectionModal({
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("updating_collection")); const load = toast.loading("Updating...");
await updateCollection.mutateAsync(collection, { let response;
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) { response = await updateCollection(collection as any);
toast.error(error.message);
} else { toast.dismiss(load);
onClose();
toast.success(t("updated")); if (response.ok) {
} toast.success(`Updated!`);
}, onClose();
}); } else toast.error(response.data as string);
setSubmitLoader(false);
} }
}; };
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("edit_collection_info")}</p> <p className="text-xl font-thin">Edit Collection Info</p>
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="flex gap-3 items-end"> <div className="w-full">
<IconPicker <p className="mb-2">Name</p>
color={collection.color} <div className="flex flex-col gap-3">
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: "#0ea5e9",
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput <TextInput
className="bg-base-200" className="bg-base-200"
value={collection.name} value={collection.name}
placeholder={t("collection_name_placeholder")} placeholder="e.g. Example Collection"
onChange={(e) => onChange={(e) =>
setCollection({ ...collection, name: e.target.value }) 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> </div>
<div className="w-full"> <div className="w-full">
<p className="mb-2">{t("description")}</p> <p className="mb-2">Description</p>
<textarea <textarea
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary" 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={t("collection_description_placeholder")} placeholder="The purpose of this Collection..."
value={collection.description} value={collection.description}
onChange={(e) => onChange={(e) =>
setCollection({ ...collection, description: e.target.value }) setCollection({
...collection,
description: e.target.value,
})
} }
/> />
</div> </div>
@@ -110,7 +110,7 @@ export default function EditCollectionModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto" className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit} onClick={submit}
> >
{t("save_changes")} Save
</button> </button>
</div> </div>
</Modal> </Modal>
@@ -1,22 +1,14 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
AccountSettings,
CollectionIncludingMembersAndLinkCount,
Member,
} from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto"; import ProfilePhoto from "../ProfilePhoto";
import addMemberToCollection from "@/lib/client/addMemberToCollection"; import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Modal from "../Modal"; import Modal from "../Modal";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import { useUser } from "@/hooks/store/user";
import CopyButton from "../CopyButton";
import { useRouter } from "next/router";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -27,13 +19,11 @@ export default function EditCollectionSharingModal({
onClose, onClose,
activeCollection, activeCollection,
}: Props) { }: Props) {
const { t } = useTranslation();
const [collection, setCollection] = const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection); useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const updateCollection = useUpdateCollection(); const { updateCollection } = useCollectionStore();
const submit = async () => { const submit = async () => {
if (!submitLoader) { if (!submitLoader) {
@@ -42,36 +32,40 @@ export default function EditCollectionSharingModal({
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("updating_collection")); const load = toast.loading("Updating...");
await updateCollection.mutateAsync(collection, { let response;
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) { response = await updateCollection(collection as any);
toast.error(error.message);
} else { toast.dismiss(load);
onClose();
toast.success(t("updated")); if (response.ok) {
} toast.success(`Updated!`);
}, onClose();
}); } else toast.error(response.data as string);
setSubmitLoader(false);
} }
}; };
const { data: user = {} } = useUser(); const { account } = useAccountStore();
const permissions = usePermissions(collection.id as number); const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL); const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`; const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [memberIdentifier, setMemberIdentifier] = useState(""); const [memberUsername, setMemberUsername] = useState("");
const [collectionOwner, setCollectionOwner] = useState< const [collectionOwner, setCollectionOwner] = useState({
Partial<AccountSettings> id: null as unknown as number,
>({}); name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
@@ -92,27 +86,21 @@ export default function EditCollectionSharingModal({
members: [...collection.members, newMember], members: [...collection.members, newMember],
}); });
setMemberIdentifier(""); setMemberUsername("");
}; };
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin"> <p className="text-xl font-thin">
{permissions === true && !isPublicRoute {permissions === true ? "Share and Collaborate" : "Team"}
? t("share_and_collaborate")
: t("team")}
</p> </p>
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{permissions === true && !isPublicRoute && ( {permissions === true && (
<div> <div>
<p>{t("make_collection_public")}</p> <p>Make Public</p>
<label className="label cursor-pointer justify-start gap-2"> <label className="label cursor-pointer justify-start gap-2">
<input <input
@@ -126,49 +114,55 @@ export default function EditCollectionSharingModal({
} }
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
/> />
<span className="label-text"> <span className="label-text">Make this a public collection</span>
{t("make_collection_public_checkbox")}
</span>
</label> </label>
<p className="text-neutral text-sm"> <p className="text-neutral text-sm">
{t("make_collection_public_desc")} This will let <b>Anyone</b> to view this collection and it&apos;s
users.
</p> </p>
</div> </div>
)} )}
{collection.isPublic && ( {collection.isPublic ? (
<div> <div className={permissions === true ? "pl-5" : ""}>
<p className="mb-2">{t("sharable_link")}</p> <p className="mb-2">Sharable Link (Click to copy)</p>
<div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between"> <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} {publicCollectionURL}
<CopyButton text={publicCollectionURL} />
</div> </div>
</div> </div>
)} ) : null}
{permissions === true && !isPublicRoute && ( {permissions === true && <div className="divider my-3"></div>}
<div className="divider my-3"></div>
)}
{permissions === true && !isPublicRoute && ( {permissions === true && (
<> <>
<p>{t("members")}</p> <p>Members</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TextInput <TextInput
value={memberIdentifier || ""} value={memberUsername || ""}
className="bg-base-200" className="bg-base-200"
placeholder={t("add_member_placeholder")} placeholder="Username (without the '@')"
onChange={(e) => setMemberIdentifier(e.target.value)} onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) => onKeyDown={(e) =>
e.key === "Enter" && e.key === "Enter" &&
addMemberToCollection( addMemberToCollection(
user, account.username as string,
memberIdentifier.replace(/^@/, "") || "", memberUsername || "",
collection, collection,
setMemberState, setMemberState
t
) )
} }
/> />
@@ -176,11 +170,10 @@ export default function EditCollectionSharingModal({
<div <div
onClick={() => onClick={() =>
addMemberToCollection( addMemberToCollection(
user, account.username as string,
memberIdentifier.replace(/^@/, "") || "", memberUsername || "",
collection, collection,
setMemberState, setMemberState
t
) )
} }
className="btn btn-accent dark:border-violet-400 text-white btn-square btn-sm h-10 w-10" className="btn btn-accent dark:border-violet-400 text-white btn-square btn-sm h-10 w-10"
@@ -220,7 +213,7 @@ export default function EditCollectionSharingModal({
</div> </div>
</div> </div>
<div> <div>
<p className="text-sm font-bold">{t("owner")}</p> <p className="text-sm font-bold">Owner</p>
</div> </div>
</div> </div>
</div> </div>
@@ -232,16 +225,19 @@ export default function EditCollectionSharingModal({
.map((e, i) => { .map((e, i) => {
const roleLabel = const roleLabel =
e.canCreate && e.canUpdate && e.canDelete e.canCreate && e.canUpdate && e.canDelete
? t("admin") ? "Admin"
: e.canCreate && !e.canUpdate && !e.canDelete : e.canCreate && !e.canUpdate && !e.canDelete
? t("contributor") ? "Contributor"
: !e.canCreate && !e.canUpdate && !e.canDelete : !e.canCreate && !e.canUpdate && !e.canDelete
? t("viewer") ? "Viewer"
: undefined; : undefined;
return ( return (
<React.Fragment key={i}> <>
<div className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"> <div
key={i}
className="relative p-3 bg-base-200 rounded-xl flex gap-2 justify-between border-none"
>
<div <div
className={"flex items-center justify-between w-full"} className={"flex items-center justify-between w-full"}
> >
@@ -263,18 +259,17 @@ export default function EditCollectionSharingModal({
</div> </div>
<div className={"flex items-center gap-2"}> <div className={"flex items-center gap-2"}>
{permissions === true && !isPublicRoute ? ( {permissions === true ? (
<div className="dropdown dropdown-bottom dropdown-end"> <div className="dropdown dropdown-bottom dropdown-end">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-primary font-normal" className="btn btn-sm btn-primary font-normal"
> >
{roleLabel} {roleLabel}
<i className="bi-chevron-down"></i> <i className="bi-chevron-down"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-64 mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@@ -313,12 +308,8 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold whitespace-nowrap"> <p className="font-bold">Viewer</p>
{t("viewer")} <p>Read-only access</p>
</p>
<p className="whitespace-nowrap">
{t("viewer_desc")}
</p>
</div> </div>
</label> </label>
</li> </li>
@@ -360,12 +351,8 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold whitespace-nowrap"> <p className="font-bold">Contributor</p>
{t("contributor")} <p>Can view and create Links</p>
</p>
<p className="whitespace-nowrap">
{t("contributor_desc")}
</p>
</div> </div>
</label> </label>
</li> </li>
@@ -407,12 +394,8 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold whitespace-nowrap"> <p className="font-bold">Admin</p>
{t("admin")} <p>Full access to all Links</p>
</p>
<p className="whitespace-nowrap">
{t("admin_desc")}
</p>
</div> </div>
</label> </label>
</li> </li>
@@ -424,12 +407,12 @@ export default function EditCollectionSharingModal({
</p> </p>
)} )}
{permissions === true && !isPublicRoute && ( {permissions === true && (
<i <i
className={ 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" "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={t("remove_member")} title="Remove Member"
onClick={() => { onClick={() => {
const updatedMembers = const updatedMembers =
collection.members.filter((member) => { collection.members.filter((member) => {
@@ -448,19 +431,19 @@ export default function EditCollectionSharingModal({
</div> </div>
</div> </div>
<div className="divider my-0 last:hidden h-[3px]"></div> <div className="divider my-0 last:hidden h-[3px]"></div>
</React.Fragment> </>
); );
})} })}
</div> </div>
</> </>
)} )}
{permissions === true && !isPublicRoute && ( {permissions === true && (
<button <button
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3" className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto mt-3"
onClick={submit} onClick={submit}
> >
{t("save_changes")} Save
</button> </button>
)} )}
</div> </div>
+165
View File
@@ -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>
);
}
@@ -1,74 +0,0 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
onSubmit: Function;
oldEmail: string;
newEmail: string;
};
export default function EmailChangeVerificationModal({
onClose,
onSubmit,
oldEmail,
newEmail,
}: Props) {
const { t } = useTranslation();
const [password, setPassword] = useState("");
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("confirm_password")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-5">
<p>
{t("password_change_warning")}
{process.env.NEXT_PUBLIC_STRIPE === "true" && t("stripe_update_note")}
</p>
<p>
{t("sso_will_be_removed_warning", {
service:
process.env.NEXT_PUBLIC_GOOGLE_ENABLED === "true" ? "Google" : "",
})}
</p>
<div>
<p>{t("old_email")}</p>
<p className="text-neutral">{oldEmail}</p>
</div>
<div>
<p>{t("new_email")}</p>
<p className="text-neutral">{newEmail}</p>
</div>
<div className="w-full">
<p className="mb-2">{t("password")}</p>
<TextInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••••"
className="bg-base-200"
type="password"
autoFocus
/>
</div>
<div className="flex justify-end items-center">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={() => onSubmit(password)}
>
{t("confirm")}
</button>
</div>
</div>
</Modal>
);
}
-131
View File
@@ -1,131 +0,0 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import TextInput from "../TextInput";
import { FormEvent, useState } from "react";
import { useTranslation, Trans } from "next-i18next";
import { useAddUser } from "@/hooks/store/admin/users";
import Link from "next/link";
import { signIn } from "next-auth/react";
type Props = {
onClose: Function;
};
type FormData = {
username?: string;
email?: string;
invite: boolean;
};
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function InviteModal({ onClose }: Props) {
const { t } = useTranslation();
const addUser = useAddUser();
const [form, setForm] = useState<FormData>({
username: emailEnabled ? undefined : "",
email: emailEnabled ? "" : undefined,
invite: true,
});
const [submitLoader, setSubmitLoader] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!submitLoader) {
const checkFields = () => {
if (emailEnabled) {
return form.email !== "";
} else {
return form.username !== "";
}
};
if (checkFields()) {
setSubmitLoader(true);
await addUser.mutateAsync(form, {
onSettled: () => {
setSubmitLoader(false);
},
onSuccess: async () => {
await signIn("invite", {
email: form.email,
callbackUrl: "/member-onboarding",
redirect: false,
});
onClose();
},
});
} else {
toast.error(t("fill_all_fields_error"));
}
}
}
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("invite_user")}</p>
<div className="divider mb-3 mt-1"></div>
<p className="mb-3">{t("invite_user_desc")}</p>
<form onSubmit={submit}>
{emailEnabled ? (
<div>
<TextInput
placeholder={t("placeholder_email")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, email: e.target.value })}
value={form.email}
/>
</div>
) : (
<div>
<p className="mb-2">
{t("username")}{" "}
{emailEnabled && (
<span className="text-xs text-neutral">{t("optional")}</span>
)}
</p>
<TextInput
placeholder={t("placeholder_john")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, username: e.target.value })}
value={form.username}
/>
</div>
)}
<div role="note" className="alert alert-note mt-5">
<i className="bi-exclamation-triangle text-xl" />
<span>
<p>{t("invite_user_note")}</p>
<p className="mb-1">
{t("invite_user_price", {
price: 4,
priceAnnual: 36,
})}
</p>
<Link
href="https://docs.linkwarden.app/billing/seats#how-seats-affect-billing"
className="font-semibold whitespace-nowrap hover:opacity-80 duration-100"
target="_blank"
>
{t("learn_more")} <i className="bi-box-arrow-up-right"></i>
</Link>
</span>
</div>
<div className="flex justify-between items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
type="submit"
>
{t("send_invitation")}
</button>
</div>
</form>
</Modal>
);
}
-186
View File
@@ -1,186 +0,0 @@
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useDeleteLink } from "@/hooks/store/links";
import Drawer from "../Drawer";
import LinkDetails from "../LinkDetails";
import Link from "next/link";
import usePermissions from "@/hooks/usePermissions";
import { useRouter } from "next/router";
import { dropdownTriggerer } from "@/lib/client/utils";
import toast from "react-hot-toast";
import clsx from "clsx";
type Props = {
onClose: Function;
onDelete: Function;
onUpdateArchive: Function;
onPin: Function;
link: LinkIncludingShortenedCollectionAndTags;
activeMode?: "view" | "edit";
};
export default function LinkModal({
onClose,
onDelete,
onUpdateArchive,
onPin,
link,
activeMode,
}: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const deleteLink = useDeleteLink();
const [mode, setMode] = useState<"view" | "edit">(activeMode || "view");
return (
<Drawer
toggleDrawer={onClose}
className="sm:h-screen items-center relative"
>
<div className="absolute top-3 left-0 right-0 flex justify-between px-3">
<div
className="bi-x text-xl btn btn-sm btn-circle text-base-content opacity-50 hover:opacity-100 z-10"
onClick={() => onClose()}
></div>
{(permissions === true || permissions?.canUpdate) && !isPublicRoute && (
<div className="flex gap-1 h-8 rounded-full bg-neutral-content bg-opacity-50 text-base-content p-1 text-xs duration-100 select-none z-10">
<div
className={clsx(
"py-1 px-2 cursor-pointer duration-100 rounded-full font-semibold",
mode === "view" && "bg-primary bg-opacity-50"
)}
onClick={() => {
setMode("view");
}}
>
View
</div>
<div
className={clsx(
"py-1 px-2 cursor-pointer duration-100 rounded-full font-semibold",
mode === "edit" && "bg-primary bg-opacity-50"
)}
onClick={() => {
setMode("edit");
}}
>
Edit
</div>
</div>
)}
<div className="flex gap-2">
{!isPublicRoute && (
<div className={`dropdown dropdown-end z-20`}>
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-sm btn-circle text-base-content opacity-50 hover:opacity-100 z-10"
>
<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`}
>
{
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
onPin();
}}
className="whitespace-nowrap"
>
{link?.pinnedBy && link.pinnedBy[0]
? t("unpin")
: t("pin_to_dashboard")}
</div>
</li>
}
{link.type === "url" &&
(permissions === true || permissions?.canUpdate) && (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
onUpdateArchive();
}}
className="whitespace-nowrap"
>
{t("refresh_preserved_formats")}
</div>
</li>
)}
{(permissions === true || permissions?.canDelete) && (
<li>
<div
role="button"
tabIndex={0}
onClick={async (e) => {
(document?.activeElement as HTMLElement)?.blur();
console.log(e.shiftKey);
if (e.shiftKey) {
const load = toast.loading(t("deleting"));
await deleteLink.mutateAsync(link.id as number, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("deleted"));
}
},
});
onClose();
} else {
onDelete();
onClose();
}
}}
className="whitespace-nowrap"
>
{t("delete")}
</div>
</li>
)}
</ul>
</div>
)}
{link.url && (
<Link
href={link.url}
target="_blank"
className="bi-box-arrow-up-right btn-circle text-base-content opacity-50 hover:opacity-100 btn btn-sm select-none z-10"
></Link>
)}
</div>
</div>
<div className="w-full">
<LinkDetails
activeLink={link}
className="sm:mt-0 -mt-11"
mode={mode}
setMode={(mode: "view" | "edit") => setMode(mode)}
onUpdateArchive={onUpdateArchive}
/>
</div>
</Drawer>
);
}
+52 -68
View File
@@ -1,27 +1,21 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput"; 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 { Collection } from "@prisma/client";
import Modal from "../Modal"; import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
type Props = { type Props = {
onClose: Function; onClose: Function;
parent?: CollectionIncludingMembersAndLinkCount;
}; };
export default function NewCollectionModal({ onClose, parent }: Props) { export default function NewCollectionModal({ onClose }: Props) {
const { t } = useTranslation();
const initial = { const initial = {
parentId: parent?.id,
name: "", name: "",
description: "", description: "",
color: "#0ea5e9", color: "#0ea5e9",
} as Partial<Collection>; };
const [collection, setCollection] = useState<Partial<Collection>>(initial); const [collection, setCollection] = useState<Partial<Collection>>(initial);
@@ -30,8 +24,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
}, []); }, []);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const { addCollection } = useCollectionStore();
const createCollection = useCreateCollection();
const submit = async () => { const submit = async () => {
if (submitLoader) return; if (submitLoader) return;
@@ -39,84 +32,75 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("creating")); const load = toast.loading("Creating...");
await createCollection.mutateAsync(collection, { let response = await addCollection(collection as any);
onSettled: (data, error) => { toast.dismiss(load);
setSubmitLoader(false);
toast.dismiss(load);
if (error) { if (response.ok) {
toast.error(error.message); toast.success("Created!");
} else { onClose();
onClose(); } else toast.error(response.data as string);
toast.success(t("created"));
} setSubmitLoader(false);
},
});
}; };
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
{parent?.id ? ( <p className="text-xl font-thin">Create a New Collection</p>
<>
<p className="text-xl font-thin">{t("new_sub_collection")}</p>
<p className="capitalize text-sm">
{t("for_collection", { name: parent.name })}
</p>
</>
) : (
<p className="text-xl font-thin">{t("create_new_collection")}</p>
)}
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="flex gap-3 items-end"> <div className="w-full">
<IconPicker <p className="mb-2">Name</p>
color={collection.color || "#0ea5e9"} <div className="flex flex-col gap-3">
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: "#0ea5e9",
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput <TextInput
className="bg-base-200" className="bg-base-200"
value={collection.name} value={collection.name}
placeholder={t("collection_name_placeholder")} placeholder="e.g. Example Collection"
onChange={(e) => onChange={(e) =>
setCollection({ ...collection, name: e.target.value }) 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> </div>
<div className="w-full"> <div className="w-full">
<p className="mb-2">{t("description")}</p> <p className="mb-2">Description</p>
<textarea <textarea
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary" 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={t("collection_description_placeholder")} placeholder="The purpose of this Collection..."
value={collection.description} value={collection.description}
onChange={(e) => onChange={(e) =>
setCollection({ ...collection, description: e.target.value }) setCollection({
...collection,
description: e.target.value,
})
} }
/> />
</div> </div>
@@ -126,7 +110,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto" className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit} onClick={submit}
> >
{t("create_collection_button")} Create Collection
</button> </button>
</div> </div>
</Modal> </Modal>
+90 -55
View File
@@ -1,74 +1,95 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection"; import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection"; import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString"; 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 { useRouter } from "next/router";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useAddLink } from "@/hooks/store/links";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation"; import Modal from "../Modal";
type Props = { type Props = {
onClose: Function; onClose: Function;
}; };
export default function NewLinkModal({ onClose }: Props) { export default function NewLinkModal({ onClose }: Props) {
const { t } = useTranslation(); const { data } = useSession();
const initial = { const initial = {
name: "", name: "",
url: "", url: "",
description: "", description: "",
type: "url", type: "url",
tags: [], tags: [],
preview: "",
image: "",
pdf: "",
readable: "",
textContent: "",
collection: { collection: {
id: undefined,
name: "", name: "",
ownerId: data?.user.id as number,
}, },
} as PostLinkSchemaType; } as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] = useState<PostLinkSchemaType>(initial); const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const addLink = useAddLink();
const { addLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter(); const router = useRouter();
const { data: collections = [] } = useCollections(); const { collections } = useCollectionStore();
const setCollection = (e: any) => { const setCollection = (e: any) => {
if (e?.__isNew__) e.value = undefined; if (e?.__isNew__) e.value = null;
setLink({ setLink({
...link, ...link,
collection: { id: e?.value, name: e?.label }, collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
}); });
}; };
const setTags = (e: any) => { const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label })); const tagNames = e.map((e: any) => {
return { name: e.label };
});
setLink({ ...link, tags: tagNames }); setLink({ ...link, tags: tagNames });
}; };
useEffect(() => { useEffect(() => {
if (router.pathname.startsWith("/collections/") && router.query.id) { if (router.query.id) {
const currentCollection = collections.find( const currentCollection = collections.find(
(e) => e.id == Number(router.query.id) (e) => e.id == Number(router.query.id)
); );
if (currentCollection && currentCollection.ownerId) if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
setLink({ setLink({
...initial, ...initial,
collection: { collection: {
id: currentCollection.id, id: currentCollection.id,
name: currentCollection.name, name: currentCollection.name,
ownerId: currentCollection.ownerId,
}, },
}); });
} else } else
setLink({ setLink({
...initial, ...initial,
collection: { name: "Unorganized" }, collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
}); });
}, []); }, []);
@@ -76,102 +97,116 @@ export default function NewLinkModal({ onClose }: Props) {
if (!submitLoader) { if (!submitLoader) {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("creating_link")); let response;
await addLink.mutateAsync(link, { const load = toast.loading("Creating...");
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) { response = await addLink(link);
toast.error(t(error.message));
} else { toast.dismiss(load);
onClose();
toast.success(t("link_created")); if (response.ok) {
} toast.success(`Created!`);
}, onClose();
}); } else toast.error(response.data as string);
setSubmitLoader(false);
return response;
} }
}; };
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("create_new_link")}</p> <p className="text-xl font-thin">Create a New Link</p>
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3"> <div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5"> <div className="sm:col-span-3 col-span-5">
<p className="mb-2">{t("link")}</p> <p className="mb-2">Link</p>
<TextInput <TextInput
value={link.url || ""} value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })} onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder={t("link_url_placeholder")} placeholder="e.g. http://example.com/"
className="bg-base-200" className="bg-base-200"
/> />
</div> </div>
<div className="sm:col-span-2 col-span-5"> <div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p> <p className="mb-2">Collection</p>
{link.collection?.name && ( {link.collection.name ? (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
defaultValue={{ defaultValue={{
value: link.collection?.id, label: link.collection.name,
label: link.collection?.name || "Unorganized", value: link.collection.id,
}} }}
/> />
)} ) : null}
</div> </div>
</div> </div>
<div className={"mt-2"}> <div className={"mt-2"}>
{optionsExpanded && ( {optionsExpanded ? (
<div className="mt-5"> <div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<div> <div>
<p className="mb-2">{t("name")}</p> <p className="mb-2">Name</p>
<TextInput <TextInput
value={link.name} value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })} onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("link_name_placeholder")} placeholder="e.g. Example Link"
className="bg-base-200" className="bg-base-200"
/> />
</div> </div>
<div> <div>
<p className="mb-2">{t("tags")}</p> <p className="mb-2">Tags</p>
<TagSelection <TagSelection
onChange={setTags} onChange={setTags}
defaultValue={link.tags?.map((e) => ({ defaultValue={link.tags.map((e) => {
label: e.name, return { label: e.name, value: e.id };
value: e.id, })}
}))}
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p> <p className="mb-2">Description</p>
<textarea <textarea
value={unescapeString(link.description || "") || ""} value={unescapeString(link.description) as string}
onChange={(e) => onChange={(e) =>
setLink({ ...link, description: e.target.value }) setLink({ ...link, description: e.target.value })
} }
placeholder={t("link_description_placeholder")} placeholder="Will be auto generated if nothing is provided."
className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100" 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>
</div> </div>
)} ) : undefined}
</div> </div>
<div className="flex justify-between items-center mt-5"> <div className="flex justify-between items-center mt-5">
<div <div
onClick={() => setOptionsExpanded(!optionsExpanded)} onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`} className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
> >
<p>{optionsExpanded ? t("hide_options") : t("more_options")}</p> <p className="font-normal">
<i className={`bi-chevron-${optionsExpanded ? "up" : "down"}`}></i> {optionsExpanded ? "Hide" : "More"} Options
</p>
<i
className={`${
optionsExpanded ? "bi-chevron-up" : "bi-chevron-down"
}`}
></i>
</div> </div>
<button <button
className="btn btn-accent dark:border-violet-400 text-white" className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit} onClick={submit}
> >
{t("create_link")} Create Link
</button> </button>
</div> </div>
</Modal> </Modal>
-243
View File
@@ -1,243 +0,0 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { TokenExpiry } from "@/types/global";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { dropdownTriggerer } from "@/lib/client/utils";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useAddToken } from "@/hooks/store/tokens";
import CopyButton from "../CopyButton";
type Props = {
onClose: Function;
};
export default function NewTokenModal({ onClose }: Props) {
const { t } = useTranslation();
const [newToken, setNewToken] = useState("");
const addToken = useAddToken();
const initial = {
name: "",
expires: 0 as TokenExpiry,
};
const [token, setToken] = useState(initial as any);
const [submitLoader, setSubmitLoader] = useState(false);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("creating_token"));
await addToken.mutateAsync(token, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setNewToken(data.secretKey);
}
},
});
}
};
const getLabel = (expiry: TokenExpiry) => {
switch (expiry) {
case TokenExpiry.sevenDays:
return t("7_days");
case TokenExpiry.oneMonth:
return t("30_days");
case TokenExpiry.twoMonths:
return t("60_days");
case TokenExpiry.threeMonths:
return t("90_days");
case TokenExpiry.never:
return t("no_expiration");
}
};
return (
<Modal toggleModal={onClose}>
{newToken ? (
<div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">{t("access_token_created")}</p>
<p>{t("token_creation_notice")}</p>
<div className="relative">
<div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between pr-14">
{newToken}
<div className="absolute right-0 px-2 border-neutral-content border-solid border-r bg-base-200">
<CopyButton text={newToken} />
</div>
</div>
</div>
</div>
) : (
<>
<p className="text-xl font-thin">{t("create_access_token")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex sm:flex-row flex-col gap-2 items-center">
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
value={token.name}
onChange={(e) => setToken({ ...token, name: e.target.value })}
placeholder={t("token_name_placeholder")}
className="bg-base-200"
/>
</div>
<div className="w-full sm:w-fit">
<p className="mb-2">{t("expires_in")}</p>
<div className="dropdown dropdown-bottom dropdown-end w-full">
<Button
tabIndex={0}
role="button"
intent="secondary"
onMouseDown={dropdownTriggerer}
className="whitespace-nowrap w-32"
>
{getLabel(token.expires)}
</Button>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl 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"
checked={token.expires === TokenExpiry.sevenDays}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.sevenDays,
});
}}
/>
<span className="label-text whitespace-nowrap">
{t("7_days")}
</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"
checked={token.expires === TokenExpiry.oneMonth}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({ ...token, expires: TokenExpiry.oneMonth });
}}
/>
<span className="label-text whitespace-nowrap">
{t("30_days")}
</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"
checked={token.expires === TokenExpiry.twoMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.twoMonths,
});
}}
/>
<span className="label-text whitespace-nowrap">
{t("60_days")}
</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"
checked={token.expires === TokenExpiry.threeMonths}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.threeMonths,
});
}}
/>
<span className="label-text whitespace-nowrap">
{t("90_days")}
</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"
checked={token.expires === TokenExpiry.never}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({ ...token, expires: TokenExpiry.never });
}}
/>
<span className="label-text whitespace-nowrap">
{t("no_expiration")}
</span>
</label>
</li>
</ul>
</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}
>
{t("create_token")}
</button>
</div>
</>
)}
</Modal>
);
}
-145
View File
@@ -1,145 +0,0 @@
import toast from "react-hot-toast";
import Modal from "../Modal";
import TextInput from "../TextInput";
import { FormEvent, useState } from "react";
import { useTranslation, Trans } from "next-i18next";
import { useAddUser } from "@/hooks/store/admin/users";
type Props = {
onClose: Function;
};
type FormData = {
name: string;
username?: string;
email?: string;
password: string;
};
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
export default function NewUserModal({ onClose }: Props) {
const { t } = useTranslation();
const addUser = useAddUser();
const [form, setForm] = useState<FormData>({
name: "",
username: "",
email: emailEnabled ? "" : undefined,
password: "",
});
const [submitLoader, setSubmitLoader] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!submitLoader) {
if (form.password.length < 8)
return toast.error(t("password_length_error"));
const checkFields = () => {
if (emailEnabled) {
return form.name !== "" && form.email !== "" && form.password !== "";
} else {
return (
form.name !== "" && form.username !== "" && form.password !== ""
);
}
};
if (checkFields()) {
setSubmitLoader(true);
await addUser.mutateAsync(form, {
onSuccess: () => {
onClose();
},
onSettled: () => {
setSubmitLoader(false);
},
});
} else {
toast.error(t("fill_all_fields_error"));
}
}
}
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("create_new_user")}</p>
<div className="divider mb-3 mt-1"></div>
<form onSubmit={submit}>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("display_name")}</p>
<TextInput
placeholder={t("placeholder_johnny")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.name}
/>
</div>
{emailEnabled && (
<div>
<p className="mb-2">{t("email")}</p>
<TextInput
placeholder={t("placeholder_email")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, email: e.target.value })}
value={form.email}
/>
</div>
)}
<div>
<p className="mb-2">
{t("username")}{" "}
{emailEnabled && (
<span className="text-xs text-neutral">{t("optional")}</span>
)}
</p>
<TextInput
placeholder={t("placeholder_john")}
className="bg-base-200"
onChange={(e) => setForm({ ...form, username: e.target.value })}
value={form.username}
/>
</div>
<div>
<p className="mb-2">{t("password")}</p>
<TextInput
placeholder="••••••••••••••"
className="bg-base-200"
onChange={(e) => setForm({ ...form, password: e.target.value })}
value={form.password}
/>
</div>
</div>
<div role="note" className="alert alert-note mt-5">
<i className="bi-exclamation-triangle text-xl" />
<span>
<Trans
i18nKey="password_change_note"
components={[<b key={0} />]}
/>
</span>
</div>
<div className="flex justify-between items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
type="submit"
>
{t("create_user")}
</button>
</div>
</form>
</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>
);
}
@@ -1,57 +0,0 @@
import React, { useEffect, useState } from "react";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { AccessToken } from "@prisma/client";
import { useRevokeToken } from "@/hooks/store/tokens";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
activeToken: AccessToken;
};
export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const { t } = useTranslation();
const [token, setToken] = useState<AccessToken>(activeToken);
const revokeToken = useRevokeToken();
useEffect(() => {
setToken(activeToken);
}, [activeToken]);
const deleteLink = async () => {
const load = toast.loading(t("deleting"));
await revokeToken.mutateAsync(token.id, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("token_revoked"));
}
},
});
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("revoke_token")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("revoke_confirmation")}</p>
<Button className="ml-auto" intent="destructive" onClick={deleteLink}>
<i className="bi-trash text-xl" />
{t("revoke")}
</Button>
</div>
</Modal>
);
}
-67
View File
@@ -1,67 +0,0 @@
import React, { useState } from "react";
import Modal from "../Modal";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
type Props = {
onClose: Function;
submit: Function;
};
export default function SurveyModal({ onClose, submit }: Props) {
const { t } = useTranslation();
const [referer, setReferrer] = useState("rather_not_say");
const [other, setOther] = useState("");
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("quick_survey")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-4">
<p>{t("how_did_you_discover_linkwarden")}</p>
<select
onChange={(e) => {
setReferrer(e.target.value);
setOther("");
}}
className="select border border-neutral-content focus:outline-none focus:border-primary duration-100 w-full bg-base-200 rounded-[0.375rem] min-h-0 h-[2.625rem] leading-4 p-2"
>
<option value="rather_not_say">{t("rather_not_say")}</option>
<option value="search_engine">{t("search_engine")}</option>
<option value="people_recommendation">
{t("people_recommendation")}
</option>
<option value="reddit">{t("reddit")}</option>
<option value="github">{t("github")}</option>
<option value="twitter">{t("twitter")}</option>
<option value="mastodon">{t("mastodon")}</option>
<option value="lemmy">{t("lemmy")}</option>
<option value="other">{t("other")}</option>
</select>
{referer === "other" && (
<input
type="text"
placeholder={t("please_specify")}
onChange={(e) => {
setOther(e.target.value);
}}
value={other}
className="input border border-neutral-content focus:border-primary focus:outline-none duration-100 w-full bg-base-200 rounded-[0.375rem] min-h-0 h-[2.625rem] leading-4 p-2"
/>
)}
<Button
className="ml-auto mt-3"
intent="accent"
onClick={() => submit(referer, other)}
>
{t("submit")}
</Button>
</div>
</Modal>
);
}
+87 -64
View File
@@ -3,53 +3,60 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection"; import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput"; import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import { import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat, ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUploadFile } from "@/hooks/store/links";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
type Props = { type Props = {
onClose: Function; onClose: Function;
}; };
export default function UploadFileModal({ onClose }: Props) { export default function UploadFileModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession(); const { data } = useSession();
const initial = { const initial = {
name: "", name: "",
url: "",
description: "", description: "",
type: "url", type: "url",
tags: [], tags: [],
preview: "",
image: "",
pdf: "",
readable: "",
textContent: "",
collection: { collection: {
id: undefined,
name: "", name: "",
ownerId: data?.user.id as number,
}, },
} as PostLinkSchemaType; } as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const [file, setFile] = useState<File>(); const [file, setFile] = useState<File>();
const uploadFile = useUploadFile(); const { addLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter(); const router = useRouter();
const { data: collections = [] } = useCollections(); const { collections } = useCollectionStore();
const setCollection = (e: any) => { const setCollection = (e: any) => {
if (e?.__isNew__) e.value = undefined; if (e?.__isNew__) e.value = null;
setLink({ setLink({
...link, ...link,
collection: { id: e?.value, name: e?.label }, collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
}); });
}; };
@@ -63,7 +70,7 @@ export default function UploadFileModal({ onClose }: Props) {
useEffect(() => { useEffect(() => {
setOptionsExpanded(false); setOptionsExpanded(false);
if (router.pathname.startsWith("/collections/") && router.query.id) { if (router.query.id) {
const currentCollection = collections.find( const currentCollection = collections.find(
(e) => e.id == Number(router.query.id) (e) => e.id == Number(router.query.id)
); );
@@ -78,19 +85,23 @@ export default function UploadFileModal({ onClose }: Props) {
collection: { collection: {
id: currentCollection.id, id: currentCollection.id,
name: currentCollection.name, name: currentCollection.name,
ownerId: currentCollection.ownerId,
}, },
}); });
} else } else
setLink({ setLink({
...initial, ...initial,
collection: { name: "Unorganized" }, collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
}); });
}, [router, collections]); }, []);
const submit = async () => { const submit = async () => {
if (!submitLoader && file) { if (!submitLoader && file) {
let fileType: ArchivedFormat | null = null; let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "monolith" | "pdf" | null = null; let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") { if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg; fileType = ArchivedFormat.jpeg;
@@ -102,43 +113,55 @@ export default function UploadFileModal({ onClose }: Props) {
fileType = ArchivedFormat.pdf; fileType = ArchivedFormat.pdf;
linkType = "pdf"; linkType = "pdf";
} }
// else if (file.type === "text/html") {
// fileType = ArchivedFormat.monolith;
// linkType = "monolith";
// }
setSubmitLoader(true); if (fileType !== null && linkType !== null) {
setSubmitLoader(true);
const load = toast.loading(t("creating")); let response;
await uploadFile.mutateAsync( const load = toast.loading("Creating...");
{ link, file },
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) { response = await addLink({
toast.error(error.message); ...link,
} else { type: linkType,
onClose(); name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
toast.success(t("created_success")); });
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 ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<p className="text-xl font-thin">{t("upload_file")}</p> <p className="text-xl font-thin">Upload File</p>
</div> </div>
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3"> <div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5"> <div className="sm:col-span-3 col-span-5">
<p className="mb-2">{t("file")}</p> <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"> <label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
<input <input
type="file" type="file"
@@ -148,74 +171,74 @@ export default function UploadFileModal({ onClose }: Props) {
/> />
</label> </label>
<p className="text-xs font-semibold mt-2"> <p className="text-xs font-semibold mt-2">
{t("file_types", { PDF, PNG, JPG (Up to {process.env.NEXT_PUBLIC_MAX_FILE_SIZE || 30}
size: process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10, MB)
})}
</p> </p>
</div> </div>
<div className="sm:col-span-2 col-span-5"> <div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p> <p className="mb-2">Collection</p>
{link.collection?.name && ( {link.collection.name ? (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
defaultValue={{ defaultValue={{
value: link.collection?.id, label: link.collection.name,
label: link.collection?.name || "Unorganized", value: link.collection.id,
}} }}
/> />
)} ) : null}
</div> </div>
</div> </div>
{optionsExpanded && ( {optionsExpanded ? (
<div className="mt-5"> <div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<div> <div>
<p className="mb-2">{t("name")}</p> <p className="mb-2">Name</p>
<TextInput <TextInput
value={link.name} value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })} onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("example_link")} placeholder="e.g. Example Link"
className="bg-base-200" className="bg-base-200"
/> />
</div> </div>
<div> <div>
<p className="mb-2">{t("tags")}</p> <p className="mb-2">Tags</p>
<TagSelection <TagSelection
onChange={setTags} onChange={setTags}
defaultValue={link.tags?.map((e) => ({ defaultValue={link.tags.map((e) => {
value: e.id, return { label: e.name, value: e.id };
label: e.name, })}
}))}
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p> <p className="mb-2">Description</p>
<textarea <textarea
value={unescapeString(link.description || "") || ""} value={unescapeString(link.description) as string}
onChange={(e) => onChange={(e) =>
setLink({ ...link, description: e.target.value }) setLink({ ...link, description: e.target.value })
} }
placeholder={t("description_placeholder")} placeholder="Will be auto generated if nothing is provided."
className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100" 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>
</div> </div>
)} ) : undefined}
<div className="flex justify-between items-center mt-5"> <div className="flex justify-between items-center mt-5">
<div <div
onClick={() => setOptionsExpanded(!optionsExpanded)} onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`} className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
> >
<p> <p>{optionsExpanded ? "Hide" : "More"} Options</p>
{optionsExpanded ? t("hide") : t("more")} {t("options")}
</p>
</div> </div>
<button <button
className="btn btn-accent dark:border-violet-400 text-white" className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit} onClick={submit}
> >
{t("upload_file")} Create Link
</button> </button>
</div> </div>
</Modal> </Modal>
+86 -37
View File
@@ -1,34 +1,48 @@
import { signOut } from "next-auth/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
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 ProfilePhoto from "@/components/ProfilePhoto";
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 NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal";
import Link from "next/link";
import UploadFileModal from "./ModalContent/UploadFileModal"; import UploadFileModal from "./ModalContent/UploadFileModal";
import { dropdownTriggerer } from "@/lib/client/utils";
import MobileNavigation from "./MobileNavigation";
import ProfileDropdown from "./ProfileDropdown";
import { useTranslation } from "next-i18next";
export default function Navbar() { export default function Navbar() {
const { t } = useTranslation(); const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore();
const router = useRouter(); const router = useRouter();
const [sidebar, setSidebar] = useState(false); const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const handleToggle = () => {
const [colorTheme, mode] = (settings.theme || "default-light").split('-');
const newMode = mode === "dark" ? "light" : "dark";
const newTheme = `${colorTheme}-${newMode}`;
updateSettings({ theme: newTheme });
};
useEffect(() => { useEffect(() => {
setSidebar(false); setSidebar(false);
document.body.style.overflow = "auto"; }, [width]);
}, [width, router]);
useEffect(() => {
setSidebar(false);
}, [router]);
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebar(false); setSidebar(!sidebar);
document.body.style.overflow = "auto";
}; };
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
@@ -38,11 +52,8 @@ export default function Navbar() {
return ( return (
<div className="flex justify-between gap-2 items-center pl-3 pr-4 py-2 border-solid border-b-neutral-content border-b"> <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={() => { onClick={toggleSidebar}
setSidebar(true); className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
document.body.style.overflow = "hidden";
}}
className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden sm:inline-flex"
> >
<i className="bi-list text-2xl leading-none"></i> <i className="bi-list text-2xl leading-none"></i>
</div> </div>
@@ -50,12 +61,11 @@ export default function Navbar() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ToggleDarkMode className="hidden sm:inline-grid" /> <ToggleDarkMode className="hidden sm:inline-grid" />
<div className="dropdown dropdown-end sm:inline-block hidden"> <div className="dropdown dropdown-end">
<div className="tooltip tooltip-bottom" data-tip={t("create_new")}> <div className="tooltip tooltip-bottom" data-tip="Create New...">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
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="flex min-w-[3.4rem] items-center btn btn-accent dark:border-violet-400 text-white btn-sm max-h-[2rem] px-2 relative"
> >
<span> <span>
@@ -66,7 +76,7 @@ export default function Navbar() {
</span> </span>
</div> </div>
</div> </div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1"> <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li> <li>
<div <div
onClick={() => { onClick={() => {
@@ -75,12 +85,11 @@ export default function Navbar() {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("new_link")} New Link
</div> </div>
</li> </li>
<li> {/* <li>
<div <div
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
@@ -88,11 +97,10 @@ export default function Navbar() {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("upload_file")} Upload File
</div> </div>
</li> </li> */}
<li> <li>
<div <div
onClick={() => { onClick={() => {
@@ -101,20 +109,59 @@ export default function Navbar() {
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
className="whitespace-nowrap"
> >
{t("new_collection")} New Collection
</div> </div>
</li> </li>
</ul> </ul>
</div> </div>
<ProfileDropdown /> <div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-circle btn-ghost">
<ProfilePhoto
src={account.image ? account.image : undefined}
priority={true}
/>
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
>
Settings
</Link>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
>
Switch to {(settings.theme || "default-light").endsWith("-dark") ? "Light" : "Dark"}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
Logout
</div>
</li>
</ul>
</div>
</div> </div>
{sidebar ? (
<MobileNavigation />
{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"> <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}> <ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg"> <div className="slide-right h-full shadow-lg">
@@ -122,14 +169,16 @@ export default function Navbar() {
</div> </div>
</ClickAwayHandler> </ClickAwayHandler>
</div> </div>
)} ) : null}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />} {newLinkModal ? (
{newCollectionModal && ( <NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} /> <NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)} ) : undefined}
{uploadFileModal && ( {uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} /> <UploadFileModal onClose={() => setUploadFileModal(false)} />
)} ) : undefined}
</div> </div>
); );
} }
+7 -5
View File
@@ -1,13 +1,11 @@
import React, { useState } from "react"; import React, { useState } from "react";
import NewLinkModal from "./ModalContent/NewLinkModal"; import NewLinkModal from "./ModalContent/NewLinkModal";
import { useTranslation } from "next-i18next";
type Props = { type Props = {
text?: string; text?: string;
}; };
export default function NoLinksFound({ text }: Props) { export default function NoLinksFound({ text }: Props) {
const { t } = useTranslation();
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
return ( return (
@@ -25,7 +23,9 @@ export default function NoLinksFound({ text }: Props) {
<p className="text-center text-xl sm:text-2xl"> <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>
<p className="text-center text-sm sm:text-base">{t("start_journey")}</p> <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 className="text-center w-full mt-4">
<div <div
onClick={() => { onClick={() => {
@@ -35,11 +35,13 @@ export default function NoLinksFound({ text }: Props) {
> >
<i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i> <i className="bi-plus-lg text-3xl left-2 group-hover:ml-[4rem] absolute duration-100"></i>
<span className="group-hover:opacity-0 text-right w-full duration-100"> <span className="group-hover:opacity-0 text-right w-full duration-100">
{t("create_new_link")} Create New Link
</span> </span>
</div> </div>
</div> </div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />} {newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
</div> </div>
); );
} }
+1 -1
View File
@@ -12,7 +12,7 @@ export default function PageHeader({
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<i <i
className={`${icon} text-primary sm:text-3xl text-2xl drop-shadow`} className={`${icon} text-primary text-3xl sm:text-4xl drop-shadow`}
></i> ></i>
<div> <div>
<p className="text-3xl capitalize font-thin">{title}</p> <p className="text-3xl capitalize font-thin">{title}</p>
-21
View File
@@ -1,21 +0,0 @@
import React from "react";
import ClickAwayHandler from "./ClickAwayHandler";
type Props = {
children: React.ReactNode;
onClose: Function;
className?: string;
};
const Popover = ({ children, className, onClose }: Props) => {
return (
<ClickAwayHandler
onClickOutside={() => onClose()}
className={`absolute z-50 ${className || ""}`}
>
{children}
</ClickAwayHandler>
);
};
export default Popover;
+52 -16
View File
@@ -1,15 +1,19 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { import {
ArchivedFormat, ArchivedFormat,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
type Props = { type Props = {
name: string; name: string;
icon: string; icon: string;
format: ArchivedFormat; format: ArchivedFormat;
link: LinkIncludingShortenedCollectionAndTags; activeLink: LinkIncludingShortenedCollectionAndTags;
downloadable?: boolean; downloadable?: boolean;
}; };
@@ -17,28 +21,58 @@ export default function PreservedFormatRow({
name, name,
icon, icon,
format, format,
link, activeLink,
downloadable, downloadable,
}: Props) { }: Props) {
const session = useSession();
const { getLink } = useLinkStore();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const router = useRouter(); const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined; 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 handleDownload = () => {
const path = `/api/v1/archives/${link?.id}?format=${format}`; const path = `/api/v1/archives/${link?.id}?format=${format}`;
fetch(path) fetch(path)
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
// Create a temporary link and click it to trigger the download // Create a temporary link and click it to trigger the download
const anchorElement = document.createElement("a"); const link = document.createElement("a");
anchorElement.href = path; link.href = path;
anchorElement.download = link.download = format === ArchivedFormat.pdf ? "PDF" : "Screenshot";
format === ArchivedFormat.monolith link.click();
? "Webpage"
: format === ArchivedFormat.pdf
? "PDF"
: "Screenshot";
anchorElement.click();
} else { } else {
console.error("Failed to download file"); console.error("Failed to download file");
} }
@@ -49,9 +83,11 @@ export default function PreservedFormatRow({
}; };
return ( return (
<div className="flex justify-between items-center"> <div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<i className={`${icon} text-2xl text-primary`} /> <div className="bg-primary text-primary-content p-2 rounded-l-md">
<i className={`${icon} text-2xl`} />
</div>
<p>{name}</p> <p>{name}</p>
</div> </div>
@@ -59,7 +95,7 @@ export default function PreservedFormatRow({
{downloadable || false ? ( {downloadable || false ? (
<div <div
onClick={() => handleDownload()} onClick={() => handleDownload()}
className="btn btn-sm btn-square btn-ghost" className="btn btn-sm btn-square"
> >
<i className="bi-cloud-arrow-down text-xl text-neutral" /> <i className="bi-cloud-arrow-down text-xl text-neutral" />
</div> </div>
@@ -70,9 +106,9 @@ export default function PreservedFormatRow({
isPublic ? "/public" : "" isPublic ? "/public" : ""
}/preserved/${link?.id}?format=${format}`} }/preserved/${link?.id}?format=${format}`}
target="_blank" target="_blank"
className="btn btn-sm btn-square btn-ghost" className="btn btn-sm btn-square"
> >
<i className="bi-box-arrow-up-right text-lg text-neutral" /> <i className="bi-box-arrow-up-right text-xl text-neutral" />
</Link> </Link>
</div> </div>
</div> </div>
-92
View File
@@ -1,92 +0,0 @@
import useLocalSettingsStore from "@/store/localSettings";
import { dropdownTriggerer } from "@/lib/client/utils";
import ProfilePhoto from "./ProfilePhoto";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
export default function ProfileDropdown() {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const { data: user = {} } = useUser();
const isAdmin = user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1);
const handleToggle = () => {
const newTheme = settings.theme === "dark" ? "light" : "dark";
updateSettings({ theme: newTheme });
};
return (
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-circle btn-ghost"
>
<ProfilePhoto
src={user.image ? user.image : undefined}
priority={true}
/>
</div>
<ul
className={`dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box mt-1`}
>
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("settings")}
</Link>
</li>
<li className="block sm:hidden">
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("switch_to", {
theme: settings.theme === "light" ? t("dark") : t("light"),
})}
</div>
</li>
{isAdmin && (
<li>
<Link
href="/admin"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("server_administration")}
</Link>
</li>
)}
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
className="whitespace-nowrap"
>
{t("logout")}
</div>
</li>
</ul>
</div>
);
}
+3 -3
View File
@@ -5,7 +5,7 @@ type Props = {
src?: string; src?: string;
className?: string; className?: string;
priority?: boolean; priority?: boolean;
name?: string | null; name?: string;
large?: boolean; large?: boolean;
}; };
@@ -19,7 +19,7 @@ export default function ProfilePhoto({
const [image, setImage] = useState(""); const [image, setImage] = useState("");
useEffect(() => { useEffect(() => {
if (src && !src?.includes("base64") && !src.startsWith("http")) if (src && !src?.includes("base64"))
setImage(`/api/v1/${src.replace("uploads/", "").replace(".jpg", "")}`); setImage(`/api/v1/${src.replace("uploads/", "").replace(".jpg", "")}`);
else if (!src) setImage(""); else if (!src) setImage("");
else { else {
@@ -45,7 +45,7 @@ export default function ProfilePhoto({
<div <div
className={`avatar skeleton rounded-full drop-shadow-md ${ className={`avatar skeleton rounded-full drop-shadow-md ${
className || "" className || ""
} ${large ? "w-28 h-28" : "w-8 h-8"}`} } ${large || "w-8 h-8"}`}
> >
<div className="rounded-full w-full h-full ring-2 ring-neutral-content"> <div className="rounded-full w-full h-full ring-2 ring-neutral-content">
<Image <Image
+8
View File
@@ -16,6 +16,14 @@ export default function RadioButton({ label, state, onClick }: Props) {
checked={state} checked={state}
onChange={onClick} onChange={onClick}
/> />
{/*<FontAwesomeIcon*/}
{/* icon={faCircleCheck}*/}
{/* className="w-5 h-5 text-primary peer-checked:block hidden"*/}
{/*/>*/}
{/*<FontAwesomeIcon*/}
{/* icon={faCircle}*/}
{/* className="w-5 h-5 text-primary peer-checked:hidden block"*/}
{/*/>*/}
<span className="rounded select-none">{label}</span> <span className="rounded select-none">{label}</span>
</label> </label>
); );
+26 -50
View File
@@ -1,6 +1,7 @@
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { readabilityAvailable } from "@/lib/shared/getArchiveValidity"; import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import useLinkStore from "@/store/links";
import { import {
ArchivedFormat, ArchivedFormat,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
@@ -11,10 +12,6 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useGetLink } from "@/hooks/store/links";
import { IconWeight } from "@phosphor-icons/react";
import Icon from "./Icon";
type LinkContent = { type LinkContent = {
title: string; title: string;
@@ -33,18 +30,15 @@ type Props = {
}; };
export default function ReadableView({ link }: Props) { export default function ReadableView({ link }: Props) {
const { t } = useTranslation();
const [linkContent, setLinkContent] = useState<LinkContent>(); const [linkContent, setLinkContent] = useState<LinkContent>();
const [imageError, setImageError] = useState<boolean>(false); const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>(); const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const [date, setDate] = useState<Date | string>();
const colorThief = new ColorThief(); const colorThief = new ColorThief();
const router = useRouter(); const router = useRouter();
const getLink = useGetLink(); const { links, getLink } = useLinkStore();
useEffect(() => { useEffect(() => {
const fetchLinkContent = async () => { const fetchLinkContent = async () => {
@@ -60,32 +54,22 @@ export default function ReadableView({ link }: Props) {
}; };
fetchLinkContent(); fetchLinkContent();
setDate(link.importDate || link.createdAt);
}, [link]); }, [link]);
useEffect(() => { useEffect(() => {
if (link) getLink.mutateAsync({ id: link.id as number }); if (link) getLink(link?.id as number);
let interval: NodeJS.Timeout | null = null; let interval: any;
if ( if (
link && link &&
(link?.image === "pending" || (link?.image === "pending" ||
link?.pdf === "pending" || link?.pdf === "pending" ||
link?.readable === "pending" || link?.readable === "pending" ||
link?.monolith === "pending" ||
!link?.image || !link?.image ||
!link?.pdf || !link?.pdf ||
!link?.readable || !link?.readable)
!link?.monolith)
) { ) {
interval = setInterval( interval = setInterval(() => getLink(link.id as number), 5000);
() =>
getLink.mutateAsync({
id: link.id as number,
}),
5000
);
} else { } else {
if (interval) { if (interval) {
clearInterval(interval); clearInterval(interval);
@@ -97,7 +81,7 @@ export default function ReadableView({ link }: Props) {
clearInterval(interval); clearInterval(interval);
} }
}; };
}, [link?.image, link?.pdf, link?.readable, link?.monolith]); }, [link?.image, link?.pdf, link?.readable]);
const rgbToHex = (r: number, g: number, b: number): string => const rgbToHex = (r: number, g: number, b: number): string =>
"#" + "#" +
@@ -140,10 +124,10 @@ export default function ReadableView({ link }: Props) {
}, [colorPalette]); }, [colorPalette]);
return ( return (
<div className={`flex flex-col max-w-screen-md h-full mx-auto p-5`}> <div className={`flex flex-col max-w-screen-md h-full mx-auto py-5`}>
<div <div
id="link-banner" id="link-banner"
className="link-banner relative bg-opacity-10 border-neutral-content p-3 border mb-3" 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 id="link-banner-inner" className="link-banner-inner"></div>
@@ -176,12 +160,12 @@ export default function ReadableView({ link }: Props) {
/> />
)} )}
<div className="flex flex-col"> <div className="flex flex-col">
<p className="text-xl pr-10"> <p className="text-xl">
{unescapeString( {unescapeString(
link?.name || link?.description || link?.url || "" link?.name || link?.description || link?.url || ""
)} )}
</p> </p>
{link?.url && ( {link?.url ? (
<Link <Link
href={link?.url || ""} href={link?.url || ""}
title={link?.url} title={link?.url}
@@ -190,10 +174,11 @@ export default function ReadableView({ link }: Props) {
> >
<i className="bi-link-45deg"></i> <i className="bi-link-45deg"></i>
{isValidUrl(link?.url || "") && {isValidUrl(link?.url || "")
new URL(link?.url as string).host} ? new URL(link?.url as string).host
: undefined}
</Link> </Link>
)} ) : undefined}
</div> </div>
</div> </div>
@@ -202,21 +187,10 @@ export default function ReadableView({ link }: Props) {
href={`/collections/${link?.collection.id}`} href={`/collections/${link?.collection.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10" className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
> >
{link.collection.icon ? ( <i
<Icon className="bi-folder-fill drop-shadow text-2xl"
icon={link.collection.icon} style={{ color: link?.collection.color }}
size={30} ></i>
weight={
(link.collection.iconWeight || "regular") as IconWeight
}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: link.collection.color }}
></i>
)}
<p <p
title={link?.collection.name} title={link?.collection.name}
className="text-lg truncate max-w-[12rem]" className="text-lg truncate max-w-[12rem]"
@@ -224,7 +198,7 @@ export default function ReadableView({ link }: Props) {
{link?.collection.name} {link?.collection.name}
</p> </p>
</Link> </Link>
{link?.tags?.map((e, i) => ( {link?.tags.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10"> <Link key={i} href={`/tags/${e.id}`} className="z-10">
<p <p
title={e.name} title={e.name}
@@ -237,8 +211,8 @@ export default function ReadableView({ link }: Props) {
</div> </div>
<p className="min-w-fit text-sm text-neutral"> <p className="min-w-fit text-sm text-neutral">
{date {link?.createdAt
? new Date(date).toLocaleString("en-US", { ? new Date(link?.createdAt).toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
@@ -276,9 +250,11 @@ export default function ReadableView({ link }: Props) {
<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" /> <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> </svg>
<p className="text-center text-2xl"> <p className="text-center text-2xl">
{t("link_preservation_in_queue")} 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> </p>
<p className="text-center text-lg mt-2">{t("check_back_later")}</p>
</div> </div>
)} )}
</div> </div>
+44 -26
View File
@@ -1,21 +1,20 @@
import useCollectionStore from "@/store/collections";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
export default function SettingsSidebar({ className }: { className?: string }) { export default function SettingsSidebar({ className }: { className?: string }) {
const { t } = useTranslation(); const LINKWARDEN_VERSION = "v2.4.7";
const LINKWARDEN_VERSION = process.env.version;
const { data: user } = useUser(); const { collections } = useCollectionStore();
const router = useRouter(); const router = useRouter();
const [active, setActive] = useState(""); const [active, setActive] = useState("");
useEffect(() => { useEffect(() => {
setActive(router.asPath); setActive(router.asPath);
}, [router]); }, [router, collections]);
return ( return (
<div <div
@@ -27,69 +26,84 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<Link href="/settings/account"> <Link href="/settings/account">
<div <div
className={`${ className={`${
active === "/settings/account" active === `/settings/account`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 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`}
> >
<i className="bi-person text-primary text-2xl"></i> <i className="bi-person text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("account")}</p>
<p className="truncate w-full pr-7">Account</p>
</div> </div>
</Link> </Link>
<Link href="/settings/preference"> <Link href="/settings/appearance">
<div <div
className={`${ className={`${
active === "/settings/preference" active === `/settings/appearance`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 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`}
> >
<i className="bi-sliders text-primary text-2xl"></i> <i className="bi-palette text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("preference")}</p>
<p className="truncate w-full pr-7">Appearance</p>
</div> </div>
</Link> </Link>
<Link href="/settings/access-tokens"> <Link href="/settings/archive">
<div <div
className={`${ className={`${
active === "/settings/access-tokens" active === `/settings/archive`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
>
<i className="bi-archive text-primary text-2xl"></i>
<p className="truncate w-full pr-7">Archive</p>
</div>
</Link>
<Link href="/settings/api">
<div
className={`${
active === `/settings/api`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 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`}
> >
<i className="bi-key text-primary text-2xl"></i> <i className="bi-key text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("access_tokens")}</p> <p className="truncate w-full pr-7">API Keys</p>
</div> </div>
</Link> </Link>
<Link href="/settings/password"> <Link href="/settings/password">
<div <div
className={`${ className={`${
active === "/settings/password" active === `/settings/password`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 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`}
> >
<i className="bi-lock text-primary text-2xl"></i> <i className="bi-lock text-primary text-2xl"></i>
<p className="truncate w-full pr-7">{t("password")}</p> <p className="truncate w-full pr-7">Password</p>
</div> </div>
</Link> </Link>
{process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && ( {process.env.NEXT_PUBLIC_STRIPE ? (
<Link href="/settings/billing"> <Link href="/settings/billing">
<div <div
className={`${ className={`${
active === "/settings/billing" active === `/settings/billing`
? "bg-primary/20" ? "bg-primary/20"
: "hover:bg-neutral/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 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`}
> >
<i className="bi-credit-card text-primary text-xl"></i> <i className="bi-credit-card text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("billing")}</p> <p className="truncate w-full pr-7">Billing</p>
</div> </div>
</Link> </Link>
)} ) : undefined}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -98,38 +112,42 @@ export default function SettingsSidebar({ className }: { className?: string }) {
target="_blank" target="_blank"
className="text-neutral text-sm ml-2 hover:opacity-50 duration-100" className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
> >
{t("linkwarden_version", { version: 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-neutral/20 duration-100 py-2 px-2 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`}
> >
<i className="bi-question-circle text-primary text-xl"></i> <i className="bi-question-circle text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("help")}</p>
<p className="truncate w-full pr-7">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-neutral/20 duration-100 py-2 px-2 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`}
> >
<i className="bi-github text-primary text-xl"></i> <i className="bi-github text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("github")}</p> <p className="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-neutral/20 duration-100 py-2 px-2 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`}
> >
<i className="bi-twitter-x text-primary text-xl"></i> <i className="bi-twitter-x text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("twitter")}</p> <p className="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-neutral/20 duration-100 py-2 px-2 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`}
> >
<i className="bi-mastodon text-primary text-xl"></i> <i className="bi-mastodon text-primary text-xl"></i>
<p className="truncate w-full pr-7">{t("mastodon")}</p> <p className="truncate w-full pr-7">Mastodon</p>
</div> </div>
</Link> </Link>
</div> </div>
+59 -28
View File
@@ -1,15 +1,12 @@
import useCollectionStore from "@/store/collections";
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"; import SidebarHighlightLink from "@/components/SidebarHighlightLink";
import CollectionListing from "@/components/CollectionListing";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useTags } from "@/hooks/store/tags";
export default function Sidebar({ className }: { className?: string }) { export default function Sidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => { const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
const storedValue = localStorage.getItem("tagDisclosure"); const storedValue = localStorage.getItem("tagDisclosure");
return storedValue ? storedValue === "true" : true; return storedValue ? storedValue === "true" : true;
@@ -22,13 +19,13 @@ export default function Sidebar({ className }: { className?: string }) {
} }
); );
const { data: collections } = useCollections(); const { collections } = useCollectionStore();
const { tags } = useTagStore();
const { data: tags = [], isLoading } = useTags();
const [active, setActive] = useState("");
const router = useRouter(); const router = useRouter();
const [active, setActive] = useState("");
useEffect(() => { useEffect(() => {
localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false"); localStorage.setItem("tagDisclosure", tagDisclosure ? "true" : "false");
}, [tagDisclosure]); }, [tagDisclosure]);
@@ -47,31 +44,31 @@ export default function Sidebar({ className }: { className?: string }) {
return ( return (
<div <div
id="sidebar" id="sidebar"
className={`bg-base-200 h-full w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content p-2 z-20 ${ 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="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<SidebarHighlightLink <SidebarHighlightLink
title={t("dashboard")} title={"Dashboard"}
href={`/dashboard`} href={`/dashboard`}
icon={"bi-house"} icon={"bi-house"}
active={active === `/dashboard`} active={active === `/dashboard`}
/> />
<SidebarHighlightLink <SidebarHighlightLink
title={t("pinned")} title={"Pinned"}
href={`/links/pinned`} href={`/links/pinned`}
icon={"bi-pin-angle"} icon={"bi-pin-angle"}
active={active === `/links/pinned`} active={active === `/links/pinned`}
/> />
<SidebarHighlightLink <SidebarHighlightLink
title={t("all_links")} title={"All Links"}
href={`/links`} href={`/links`}
icon={"bi-link-45deg"} icon={"bi-link-45deg"}
active={active === `/links`} active={active === `/links`}
/> />
<SidebarHighlightLink <SidebarHighlightLink
title={t("all_collections")} title={"All Collections"}
href={`/collections`} href={`/collections`}
icon={"bi-folder"} icon={"bi-folder"}
active={active === `/collections`} active={active === `/collections`}
@@ -85,7 +82,7 @@ export default function Sidebar({ className }: { className?: string }) {
}} }}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5" className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
> >
<p className="text-sm">{t("collections")}</p> <p className="text-sm">Collections</p>
<i <i
className={`bi-chevron-down ${ className={`bi-chevron-down ${
collectionDisclosure ? "rotate-reverse" : "rotate" collectionDisclosure ? "rotate-reverse" : "rotate"
@@ -100,8 +97,48 @@ export default function Sidebar({ className }: { className?: string }) {
leaveFrom="transform opacity-100 translate-y-0" leaveFrom="transform opacity-100 translate-y-0"
leaveTo="transform opacity-0 -translate-y-3" leaveTo="transform opacity-0 -translate-y-3"
> >
<Disclosure.Panel> <Disclosure.Panel className="flex flex-col gap-1">
<CollectionListing /> {collections[0] ? (
collections
.sort((a, b) => a.name.localeCompare(b.name))
.map((e, i) => {
return (
<Link key={i} href={`/collections/${e.id}`}>
<div
className={`${
active === `/collections/${e.id}`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: e.color }}
></i>
<p className="truncate w-full">{e.name}</p>
{e.isPublic ? (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
) : undefined}
<div className="drop-shadow text-neutral text-xs">
{e._count?.links}
</div>
</div>
</Link>
);
})
) : (
<div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
<p className="text-neutral text-xs font-semibold truncate w-full pr-7">
You Have No Collections...
</p>
</div>
)}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
</Disclosure> </Disclosure>
@@ -112,7 +149,7 @@ export default function Sidebar({ className }: { className?: string }) {
}} }}
className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5" className="flex items-center justify-between w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
> >
<p className="text-sm">{t("tags")}</p> <p className="text-sm">Tags</p>
<i <i
className={`bi-chevron-down ${ className={`bi-chevron-down ${
tagDisclosure ? "rotate-reverse" : "rotate" tagDisclosure ? "rotate-reverse" : "rotate"
@@ -128,16 +165,10 @@ export default function Sidebar({ className }: { className?: string }) {
leaveTo="transform opacity-0 -translate-y-3" leaveTo="transform opacity-0 -translate-y-3"
> >
<Disclosure.Panel className="flex flex-col gap-1"> <Disclosure.Panel className="flex flex-col gap-1">
{isLoading ? ( {tags[0] ? (
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
<div className="skeleton h-4 w-full"></div>
</div>
) : tags[0] ? (
tags tags
.sort((a: any, b: any) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map((e: any, i: any) => { .map((e, i) => {
return ( return (
<Link key={i} href={`/tags/${e.id}`}> <Link key={i} href={`/tags/${e.id}`}>
<div <div
@@ -161,7 +192,7 @@ export default function Sidebar({ className }: { className?: string }) {
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-neutral text-xs font-semibold truncate w-full pr-7"> <p className="text-neutral text-xs font-semibold truncate w-full pr-7">
{t("you_have_no_tags")} You Have No Tags...
</p> </p>
</div> </div>
)} )}
+1 -2
View File
@@ -14,7 +14,6 @@ export default function SidebarHighlightLink({
return ( return (
<Link href={href}> <Link href={href}>
<div <div
title={title}
className={`${ className={`${
active || false active || false
? "bg-primary/20" ? "bg-primary/20"
@@ -29,7 +28,7 @@ export default function SidebarHighlightLink({
<i className={`${icon} text-primary text-2xl drop-shadow`}></i> <i className={`${icon} text-primary text-2xl drop-shadow`}></i>
</div> </div>
<div className={"mt-1"}> <div className={"mt-1"}>
<p className="truncate w-full font-semibold text-xs">{title}</p> <p className="truncate w-full font-semibold text-sm">{title}</p>
</div> </div>
</div> </div>
</Link> </Link>
+21 -53
View File
@@ -1,36 +1,22 @@
import React, { Dispatch, SetStateAction, useEffect } from "react"; import React, { Dispatch, SetStateAction } from "react";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
import { TFunction } from "i18next";
import useLocalSettingsStore from "@/store/localSettings";
import { resetInfiniteQueryPagination } from "@/hooks/store/links";
import { useQueryClient } from "@tanstack/react-query";
type Props = { type Props = {
sortBy: Sort; sortBy: Sort;
setSort: Dispatch<SetStateAction<Sort>>; setSort: Dispatch<SetStateAction<Sort>>;
t: TFunction<"translation", undefined>;
}; };
export default function SortDropdown({ sortBy, setSort, t }: Props) { export default function SortDropdown({ sortBy, setSort }: Props) {
const { updateSettings } = useLocalSettingsStore();
const queryClient = useQueryClient();
useEffect(() => {
updateSettings({ sortBy });
}, [sortBy]);
return ( return (
<div className="dropdown dropdown-bottom dropdown-end"> <div className="dropdown dropdown-bottom dropdown-end">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer} className="btn btn-sm btn-square btn-ghost"
className="btn btn-sm btn-square btn-ghost border-none"
> >
<i className="bi-chevron-expand text-neutral text-2xl"></i> <i className="bi-chevron-expand text-neutral text-2xl"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@@ -41,15 +27,13 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
value="Date (Newest First)"
checked={sortBy === Sort.DateNewestFirst} checked={sortBy === Sort.DateNewestFirst}
onChange={() => { onChange={() => {
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.DateNewestFirst); setSort(Sort.DateNewestFirst);
}} }}
/> />
<span className="label-text whitespace-nowrap"> <span className="label-text">Date (Newest First)</span>
{t("date_newest_first")}
</span>
</label> </label>
</li> </li>
<li> <li>
@@ -62,15 +46,11 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
value="Date (Oldest First)"
checked={sortBy === Sort.DateOldestFirst} checked={sortBy === Sort.DateOldestFirst}
onChange={() => { onChange={() => setSort(Sort.DateOldestFirst)}
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.DateOldestFirst);
}}
/> />
<span className="label-text whitespace-nowrap"> <span className="label-text">Date (Oldest First)</span>
{t("date_oldest_first")}
</span>
</label> </label>
</li> </li>
<li> <li>
@@ -83,13 +63,11 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
value="Name (A-Z)"
checked={sortBy === Sort.NameAZ} checked={sortBy === Sort.NameAZ}
onChange={() => { onChange={() => setSort(Sort.NameAZ)}
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.NameAZ);
}}
/> />
<span className="label-text whitespace-nowrap">{t("name_az")}</span> <span className="label-text">Name (A-Z)</span>
</label> </label>
</li> </li>
<li> <li>
@@ -102,13 +80,11 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
value="Name (Z-A)"
checked={sortBy === Sort.NameZA} checked={sortBy === Sort.NameZA}
onChange={() => { onChange={() => setSort(Sort.NameZA)}
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.NameZA);
}}
/> />
<span className="label-text whitespace-nowrap">{t("name_za")}</span> <span className="label-text">Name (Z-A)</span>
</label> </label>
</li> </li>
<li> <li>
@@ -121,15 +97,11 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
value="Description (A-Z)"
checked={sortBy === Sort.DescriptionAZ} checked={sortBy === Sort.DescriptionAZ}
onChange={() => { onChange={() => setSort(Sort.DescriptionAZ)}
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.DescriptionAZ);
}}
/> />
<span className="label-text whitespace-nowrap"> <span className="label-text">Description (A-Z)</span>
{t("description_az")}
</span>
</label> </label>
</li> </li>
<li> <li>
@@ -142,15 +114,11 @@ export default function SortDropdown({ sortBy, setSort, t }: Props) {
type="radio" type="radio"
name="sort-radio" name="sort-radio"
className="radio checked:bg-primary" className="radio checked:bg-primary"
value="Description (Z-A)"
checked={sortBy === Sort.DescriptionZA} checked={sortBy === Sort.DescriptionZA}
onChange={() => { onChange={() => setSort(Sort.DescriptionZA)}
resetInfiniteQueryPagination(queryClient, ["links"]);
setSort(Sort.DescriptionZA);
}}
/> />
<span className="label-text whitespace-nowrap"> <span className="label-text">Description (Z-A)</span>
{t("description_za")}
</span>
</label> </label>
</li> </li>
</ul> </ul>
-6
View File
@@ -8,8 +8,6 @@ type Props = {
onChange: ChangeEventHandler<HTMLInputElement>; onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined; onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
className?: string; className?: string;
spellCheck?: boolean;
"data-testid"?: string;
}; };
export default function TextInput({ export default function TextInput({
@@ -20,13 +18,9 @@ export default function TextInput({
onChange, onChange,
onKeyDown, onKeyDown,
className, className,
spellCheck,
"data-testid": dataTestId,
}: Props) { }: Props) {
return ( return (
<input <input
data-testid={dataTestId}
spellCheck={spellCheck}
autoFocus={autoFocus} autoFocus={autoFocus}
type={type ? type : "text"} type={type ? type : "text"}
placeholder={placeholder} placeholder={placeholder}
+37 -40
View File
@@ -1,50 +1,47 @@
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState, ChangeEvent } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import clsx from "clsx";
type Props = { type Props = {
className?: string; className?: string;
align?: "left" | "right";
}; };
export default function ToggleDarkMode({ className, align }: Props) { export default function ToggleDarkMode({ className }: Props) {
const { t } = useTranslation(); const { updateSettings } = useLocalSettingsStore();
const { settings, updateSettings } = useLocalSettingsStore(); const [theme, setTheme] = useState('default-light');
const [theme, setTheme] = useState<string | null>( useEffect(() => {
localStorage.getItem("theme") const storedTheme = localStorage.getItem("theme");
); if (storedTheme) {
setTheme(storedTheme);
} else {
// Default theme if not set in localStorage
localStorage.setItem("theme", "default-light");
setTheme("default-light");
}
console.log("Initial theme from localStorage:", storedTheme || "default-light");
}, []);
const handleToggle = (e: ChangeEvent<HTMLInputElement>) => { const handleToggle = () => {
setTheme(e.target.checked ? "dark" : "light"); const [currentColorTheme, currentMode] = theme.split('-');
}; const newMode = currentMode === 'light' ? 'dark' : 'light';
const newTheme = `${currentColorTheme}-${newMode}`;
useEffect(() => { setTheme(newTheme);
if (theme) { localStorage.setItem("theme", newTheme);
updateSettings({ theme }); document.documentElement.setAttribute('data-theme', newTheme);
} updateSettings({ theme: newTheme });
}, [theme]); console.log("New theme set:", newTheme);
};
return ( const isDarkMode = theme.endsWith('-dark');
<div
className={clsx("tooltip", align ? `tooltip-${align}` : "tooltip-bottom")} return (
data-tip={t("switch_to", { <div className="tooltip tooltip-bottom" data-tip={`Switch to ${isDarkMode ? "Light" : "Dark"}`}>
theme: settings.theme === "light" ? "Dark" : "Light", <label className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}>
})} <input type="checkbox" onChange={handleToggle} className="theme-controller" checked={isDarkMode} />
> <i className="bi-sun-fill text-xl swap-on"></i>
<label <i className="bi-moon-fill text-xl swap-off"></i>
className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`} </label>
> </div>
<input );
type="checkbox"
onChange={handleToggle}
className="theme-controller"
checked={theme === "dark"}
/>
<i className="bi-sun-fill text-xl swap-on"></i>
<i className="bi-moon-fill text-xl swap-off"></i>
</label>
</div>
);
} }
-87
View File
@@ -1,87 +0,0 @@
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
import { User as U } from "@prisma/client";
import { TFunction } from "i18next";
interface User extends U {
subscriptions: {
active: boolean;
};
}
type UserModal = {
isOpen: boolean;
userId: number | null;
};
const UserListing = (
users: User[],
deleteUserModal: UserModal,
setDeleteUserModal: Function,
t: TFunction<"translation", undefined>
) => {
return (
<div className="overflow-x-auto whitespace-nowrap w-full">
<table className="table w-full">
<thead>
<tr>
<th></th>
<th>{t("username")}</th>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<th>{t("email")}</th>
)}
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
<th>{t("subscribed")}</th>
)}
<th>{t("created_at")}</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr
key={index}
className="group hover:bg-neutral-content hover:bg-opacity-30 duration-100"
>
<td className="text-primary">{index + 1}</td>
<td>
{user.username ? user.username : <b>{t("not_available")}</b>}
</td>
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
<td>{user.email}</td>
)}
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
<td>
{user.subscriptions?.active ? (
<i className="bi bi-check text-green-500"></i>
) : (
<i className="bi bi-x text-red-500"></i>
)}
</td>
)}
<td>{new Date(user.createdAt).toLocaleString()}</td>
<td className="relative">
<button
className="btn btn-sm btn-ghost duration-100 hidden group-hover:block absolute z-20 right-[0.35rem] top-[0.35rem]"
onClick={() =>
setDeleteUserModal({ isOpen: true, userId: user.id })
}
>
<i className="bi bi-trash"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
{deleteUserModal.isOpen && deleteUserModal.userId && (
<DeleteUserModal
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
userId={deleteUserModal.userId}
/>
)}
</div>
);
};
export default UserListing;
+45 -131
View File
@@ -1,147 +1,61 @@
import React, { Dispatch, SetStateAction, useEffect } from "react"; import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { ViewMode } from "@/types/global"; import { ViewMode } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
type Props = { type Props = {
viewMode: ViewMode; viewMode: string;
setViewMode: Dispatch<SetStateAction<ViewMode>>; setViewMode: Dispatch<SetStateAction<string>>;
}; };
export default function ViewDropdown({ viewMode, setViewMode }: Props) { export default function ViewDropdown({ viewMode, setViewMode }: Props) {
const { settings, updateSettings } = useLocalSettingsStore((state) => state); const { updateSettings } = useLocalSettingsStore();
const { t } = useTranslation();
const onChangeViewMode = (
e: React.MouseEvent<HTMLButtonElement>,
viewMode: ViewMode
) => {
setViewMode(viewMode);
};
useEffect(() => { useEffect(() => {
updateSettings({ viewMode }); updateSettings({ viewMode: viewMode as ViewMode });
}, [viewMode, updateSettings]); }, [viewMode]);
const onChangeViewMode = (mode: ViewMode) => {
setViewMode(mode);
updateSettings({ viewMode });
};
const toggleShowSetting = (setting: keyof typeof settings.show) => {
const newShowSettings = {
...settings.show,
[setting]: !settings.show[setting],
};
updateSettings({ show: newShowSettings });
};
const onColumnsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
updateSettings({ columns: Number(e.target.value) });
};
return ( return (
<div className="dropdown dropdown-bottom dropdown-end"> <div className="p-1 flex flex-row gap-1 border border-neutral-content rounded-[0.625rem]">
<div <button
tabIndex={0} onClick={(e) => onChangeViewMode(e, ViewMode.Card)}
role="button" className={`btn btn-square btn-sm btn-ghost ${
onMouseDown={dropdownTriggerer} viewMode == ViewMode.Card
className="btn btn-sm btn-square btn-ghost border-none" ? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
> >
{viewMode === ViewMode.Card ? ( <i className="bi-grid w-4 h-4 text-neutral"></i>
<i className="bi-grid w-4 h-4 text-neutral"></i> </button>
) : viewMode === ViewMode.Masonry ? (
<i className="bi-columns-gap w-4 h-4 text-neutral"></i> <button
) : ( onClick={(e) => onChangeViewMode(e, ViewMode.List)}
<i className="bi-view-stacked w-4 h-4 text-neutral"></i> className={`btn btn-square btn-sm btn-ghost ${
)} viewMode == ViewMode.List
</div> ? "bg-primary/20 hover:bg-primary/20"
<ul : "hover:bg-neutral/20"
tabIndex={0} }`}
className="dropdown-content z-[30] menu shadow bg-base-200 min-w-52 border border-neutral-content rounded-xl mt-1"
> >
<p className="mb-1 text-sm text-neutral">{t("view")}</p> <i className="bi bi-view-stacked w-4 h-4 text-neutral"></i>
<div className="p-1 flex w-full justify-between gap-1 border border-neutral-content rounded-[0.625rem]"> </button>
<button
onClick={(e) => onChangeViewMode(ViewMode.Card)} {/* <button
className={`btn w-[31%] btn-sm btn-ghost ${ onClick={(e) => onChangeViewMode(e, ViewMode.Grid)}
viewMode === ViewMode.Card className={`btn btn-square btn-sm btn-ghost ${
? "bg-primary/20 hover:bg-primary/20" viewMode == ViewMode.Grid
: "hover:bg-neutral/20" ? "bg-primary/20 hover:bg-primary/20"
}`} : "hover:bg-neutral/20"
> }`}
<i className="bi-grid text-lg text-neutral"></i> >
</button> <i className="bi-columns-gap w-4 h-4 text-neutral"></i>
<button </button> */}
onClick={(e) => onChangeViewMode(ViewMode.Masonry)}
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.Masonry
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-columns-gap text-lg text-neutral"></i>
</button>
<button
onClick={(e) => onChangeViewMode(ViewMode.List)}
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.List
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi-view-stacked text-lg text-neutral"></i>
</button>
</div>
<p className="mb-1 mt-2 text-sm text-neutral">{t("show")}</p>
{Object.entries(settings.show)
.filter((e) =>
settings.viewMode === ViewMode.List // Hide tags, image, and description checkboxes in list view
? e[0] !== "tags" && e[0] !== "image" && e[0] !== "description"
: settings.viewMode === ViewMode.Card // Hide tags and description checkboxes in card view
? e[0] !== "tags" && e[0] !== "description"
: true
)
.map(([key, value]) => (
<li key={key}>
<label className="label cursor-pointer flex justify-start">
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={value}
onChange={() =>
toggleShowSetting(key as keyof typeof settings.show)
}
/>
<span className="label-text whitespace-nowrap">{t(key)}</span>
</label>
</li>
))}
{settings.viewMode !== ViewMode.List && (
<>
<p className="mb-1 mt-2 text-sm text-neutral">
{t("columns")}:{" "}
{settings.columns === 0 ? t("default") : settings.columns}
</p>
<div>
<input
type="range"
min={0}
max="8"
value={settings.columns}
onChange={(e) => onColumnsChange(e)}
className="range range-xs range-primary"
step="1"
/>
<div className="flex w-full justify-between px-2 text-xs text-neutral select-none">
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
<span>|</span>
</div>
</div>
</>
)}
</ul>
</div> </div>
); );
} }
-62
View File
@@ -1,62 +0,0 @@
import { cn } from "@/lib/client/utils";
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
"select-none relative duration-200 rounded-lg text-center w-fit flex justify-center items-center gap-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
intent: {
accent:
"bg-accent text-white hover:bg-accent/80 border border-violet-400",
primary: "bg-primary text-primary-content hover:bg-primary/80",
secondary:
"bg-neutral-content text-secondary-foreground hover:bg-neutral-content/80 border border-neutral/30",
destructive:
"bg-error text-white hover:bg-error/80 border border-neutral/60",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-content",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
small: "h-7 px-2",
medium: "h-10 px-4 py-2",
large: "h-12 px-7 py-2",
full: "px-4 py-2 w-full",
icon: "h-10 w-10",
},
loading: {
true: "cursor-wait",
},
},
defaultVariants: {
intent: "primary",
size: "medium",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button: React.FC<ButtonProps> = ({
className,
intent,
size,
children,
disabled,
loading = false,
...props
}) => (
<button
className={cn(buttonVariants({ intent, size, className }))}
disabled={loading || disabled}
{...props}
>
{children}
</button>
);
export default Button;
-17
View File
@@ -1,17 +0,0 @@
import clsx from "clsx";
import React from "react";
type Props = {
className?: string;
vertical?: boolean;
};
function Divider({ className, vertical = false }: Props) {
return vertical ? (
<hr className={clsx("border-neutral-content border-l h-full", className)} />
) : (
<hr className={clsx("border-neutral-content border-t", className)} />
);
}
export default Divider;
-272
View File
@@ -1,272 +0,0 @@
import React from "react";
type Props = {
className?: string;
color: string;
size: string;
};
const Loader = (props: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
width={props.size}
height={props.size}
className={props.className}
style={{
shapeRendering: "auto",
display: "block",
background: "rgba(255, 255, 255, 0)",
}}
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<g>
<g transform="rotate(0 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.9166666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(30 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.8333333333333334s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(60 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.75s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(90 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.6666666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(120 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.5833333333333334s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(150 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.5s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(180 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.4166666666666667s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(210 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.3333333333333333s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(240 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.25s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(270 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.16666666666666666s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(300 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="-0.08333333333333333s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g transform="rotate(330 50 50)">
<rect
fill={props.color}
height="12"
width="6"
ry="1.8"
rx="1.8"
y="24"
x="47"
>
<animate
repeatCount="indefinite"
begin="0s"
dur="1s"
keyTimes="0;1"
values="1;0"
attributeName="opacity"
></animate>
</rect>
</g>
<g></g>
</g>
</svg>
);
};
export default Loader;
-2
View File
@@ -1,2 +0,0 @@
TEST_USERNAME=test
TEST_PASSWORD=password
-18
View File
@@ -1,18 +0,0 @@
import axios, { AxiosError } from "axios"
axios.defaults.baseURL = "http://localhost:3000"
export async function seedUser(username?: string, password?: string, name?: string) {
try {
return await axios.post("/api/v1/users", {
username: username || "test",
password: password || "password",
name: name || "Test User",
})
} catch (error) {
const axiosError = error as AxiosError;
if (axiosError && axiosError.response?.status === 400) return
throw error
}
}
-10
View File
@@ -1,10 +0,0 @@
import { Locator, Page } from "playwright";
import { BasePage } from "./page";
export class DashboardPage extends BasePage {
container: Locator;
constructor(page: Page) {
super(page);
this.container = this.page.getByTestId("dashboard-wrapper");
}
}
-45
View File
@@ -1,45 +0,0 @@
import { Locator, Page } from "@playwright/test";
export class BaseModal {
page: Page;
container: Locator;
mobileContainer: Locator;
closeModalButton: Locator;
mobileModalSlider: Locator;
constructor(page: Page) {
this.page = page;
this.container = page.getByTestId("modal-container");
this.mobileContainer = page.getByTestId("mobile-modal-container");
this.closeModalButton = this.container.getByTestId("close-modal-button");
this.mobileModalSlider = this.mobileContainer.getByTestId(
"mobile-modal-slider"
);
}
async close() {
if (await this.container.isVisible()) {
await this.closeModalButton.click();
}
if (await this.mobileContainer.isVisible()) {
const box = await this.mobileModalSlider.boundingBox();
if (!box) {
return;
}
const pageHeight = await this.page.evaluate(() => window.innerHeight);
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await this.page.mouse.move(startX, startY);
await this.page.mouse.down();
await this.page.mouse.move(startX, startY + pageHeight / 2);
await this.page.mouse.up();
}
}
async isOpen() {
return (
(await this.container.isVisible()) ||
(await this.mobileContainer.isVisible())
);
}
}
-20
View File
@@ -1,20 +0,0 @@
import { Locator, Page } from "@playwright/test";
export class BasePage {
page: Page;
toastMessage: Locator;
constructor(page: Page) {
this.page = page;
this.toastMessage = this.page.getByTestId("toast-message-container");
}
async getLatestToast() {
const toast = this.toastMessage.first();
return {
locator: toast,
closeButton: toast.getByTestId("close-toast-button"),
message: toast.getByTestId("toast-message"),
};
}
}
-27
View File
@@ -1,27 +0,0 @@
import { test as baseTest } from "@playwright/test";
import { LoginPage } from "./login-page";
import { RegistrationPage } from "./registration-page";
import { DashboardPage } from "./base/dashboard-page";
export const test = baseTest.extend<{
dashboardPage: DashboardPage;
loginPage: LoginPage;
registrationPage: RegistrationPage;
}>({
page: async ({ page }, use) => {
await page.goto("/");
use(page);
},
dashboardPage: async ({ page }, use) => {
const dashboardPage = new DashboardPage(page);
await use(dashboardPage);
},
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
registrationPage: async ({ page }, use) => {
const registrationPage = new RegistrationPage(page);
await use(registrationPage);
},
});
-27
View File
@@ -1,27 +0,0 @@
import { Locator, Page } from "@playwright/test";
import { BasePage } from "./base/page";
export class LoginPage extends BasePage {
submitLoginButton: Locator;
loginForm: Locator;
registerLink: Locator;
passwordInput: Locator;
usernameInput: Locator;
constructor(page: Page) {
super(page);
this.submitLoginButton = page.getByTestId("submit-login-button");
this.loginForm = page.getByTestId("login-form");
this.registerLink = page.getByTestId("register-link");
this.passwordInput = page.getByTestId("password-input");
this.usernameInput = page.getByTestId("username-input");
}
async goto() {
await this.page.goto("/login");
}
}
-28
View File
@@ -1,28 +0,0 @@
import { Locator, Page } from "@playwright/test";
import { BasePage } from "./base/page";
export class RegistrationPage extends BasePage {
registerButton: Locator;
registrationForm: Locator;
loginLink: Locator;
displayNameInput: Locator;
passwordConfirmInput: Locator;
passwordInput: Locator;
usernameInput: Locator;
constructor(page: Page) {
super(page);
this.registerButton = page.getByTestId("register-button");
this.registrationForm = page.getByTestId("registration-form");
this.loginLink = page.getByTestId("login-link");
this.displayNameInput = page.getByTestId("display-name-input");
this.passwordConfirmInput = page.getByTestId("password-confirm-input");
this.passwordInput = page.getByTestId("password-input");
this.usernameInput = page.getByTestId("username-input");
}
}

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