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
264 changed files with 5335 additions and 14851 deletions
+8 -50
View File
@@ -1,5 +1,5 @@
NEXTAUTH_SECRET=very_sensitive_secret NEXTAUTH_SECRET=very_sensitive_secret
NEXTAUTH_URL=http://localhost:3000/api/v1/auth NEXTAUTH_URL=http://localhost:3000
# Manual installation database settings # Manual installation database settings
DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
@@ -15,24 +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=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=
@@ -46,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=
@@ -88,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=
@@ -101,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=
@@ -208,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=
-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@v2
- name: Use Node.js
uses: actions/setup-node@v3
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@v3
if: always()
with:
name: playwright-report
path: test-results
retention-days: 30
+1 -8
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).
+3 -20
View File
@@ -8,33 +8,16 @@ 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 yarn install --network-timeout 10000000 # Increase timeout to pass github actions arm64 build
RUN yarn install --network-timeout 10000000
RUN apt-get update
RUN apt-get install -y \
build-essential \
curl \
libssl-dev \
pkg-config
RUN apt-get update
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install monolith
RUN npx playwright install-deps && \ RUN npx playwright install-deps && \
apt-get clean && \ apt-get clean && \
yarn cache clean yarn cache clean
RUN yarn playwright install
COPY . . COPY . .
RUN yarn prisma generate && \ RUN yarn prisma generate && \
yarn build yarn build
CMD yarn prisma migrate deploy && yarn start CMD yarn prisma migrate deploy && yarn start
View File
+3 -9
View File
@@ -17,9 +17,7 @@
## 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.
@@ -57,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.
@@ -70,10 +68,6 @@ We've forked the old version from the current repository into [this repo](https:
- 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension) - 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension)
- ⬇️ Import 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).
- 🍎 iOS Shortcut to save links to Linkwarden.
- 🔑 API keys.
- ✅ Bulk actions.
- ✨ And so many more features! - ✨ And so many more features!
## Like what we're doing? Give us a Star ⭐ ## Like what we're doing? Give us a Star ⭐
+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);
+17 -27
View File
@@ -5,12 +5,10 @@ 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";
type Props = { type Props = {
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
@@ -18,9 +16,8 @@ type Props = {
}; };
export default function CollectionCard({ collection, className }: Props) { export default function CollectionCard({ collection, className }: Props) {
const { t } = useTranslation();
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",
@@ -39,24 +36,22 @@ export default function CollectionCard({ collection, className }: Props) {
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean, archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: 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,
}); });
} }
}; };
@@ -75,13 +70,12 @@ export default function CollectionCard({ collection, className }: Props) {
<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 w-52 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"
@@ -91,10 +85,10 @@ export default function CollectionCard({ collection, className }: Props) {
setEditCollectionModal(true); setEditCollectionModal(true);
}} }}
> >
{t("edit_collection_info")} Edit Collection Info
</div> </div>
</li> </li>
)} ) : undefined}
<li> <li>
<div <div
role="button" role="button"
@@ -104,9 +98,7 @@ export default function CollectionCard({ collection, className }: Props) {
setEditCollectionSharingModal(true); setEditCollectionSharingModal(true);
}} }}
> >
{permissions === true {permissions === true ? "Share and Collaborate" : "View Team"}
? t("share_and_collaborate")
: t("view_team")}
</div> </div>
</li> </li>
<li> <li>
@@ -118,9 +110,7 @@ export default function CollectionCard({ collection, className }: Props) {
setDeleteCollectionModal(true); setDeleteCollectionModal(true);
}} }}
> >
{permissions === true {permissions === true ? "Delete Collection" : "Leave Collection"}
? t("delete_collection")
: t("leave_collection")}
</div> </div>
</li> </li>
</ul> </ul>
@@ -180,7 +170,7 @@ export default function CollectionCard({ collection, className }: Props) {
<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} ) : undefined}
-388
View File
@@ -1,388 +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";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
}
const CollectionListing = () => {
const { t } = useTranslation();
const updateCollection = useUpdateCollection();
const { data: collections = [], isLoading } = useCollections();
const { data: user = {} } = useUser();
const updateUser = useUpdateUser();
const router = useRouter();
const currentPath = router.asPath;
const [tree, setTree] = useState<TreeData | undefined>();
const initialTree = useMemo(() => {
if (
// !tree &&
collections.length > 0
) {
return buildTreeFromCollections(
collections,
router,
user.collectionOrder
);
} else return undefined;
}, [collections, user, router]);
useEffect(() => {
// if (!tree)
setTree(initialTree);
}, [initialTree]);
useEffect(() => {
if (user.username) {
if (
(!user.collectionOrder || user.collectionOrder.length === 0) &&
collections.length > 0
)
updateUser.mutate({
...user,
collectionOrder: collections
.filter(
(e) =>
e.parentId === null ||
!collections.find((i) => i.id === e.parentId)
) // Filter out collections with non-null parentId
.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,
});
}
}
}
}, [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`}
>
{Icon(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`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
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>
) : undefined}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
</div>
</Link>
</div>
</div>
);
};
const Icon = (
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>,
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: false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
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 };
};
+3 -3
View File
@@ -9,12 +9,12 @@ export default function dashboardItem({
}) { }) {
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<div className="w-[4rem] 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">{name}</p> <p className="text-neutral text-xs tracking-wider">{name}</p>
<p className="font-thin text-5xl text-primary mt-0.5">{value}</p> <p className="font-thin text-6xl text-primary mt-0.5">{value}</p>
</div> </div>
</div> </div>
); );
+39 -44
View File
@@ -1,6 +1,4 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react"; import React from "react";
import { useTranslation } from "next-i18next";
type Props = { type Props = {
setSearchFilter: Function; setSearchFilter: Function;
@@ -17,19 +15,16 @@ export default function FilterSearchDropdown({
setSearchFilter, setSearchFilter,
searchFilter, searchFilter,
}: Props) { }: Props) {
const { t } = useTranslation();
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 w-56 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"
@@ -41,11 +36,11 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox" name="search-filter-checkbox"
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
checked={searchFilter.name} checked={searchFilter.name}
onChange={() => onChange={() => {
setSearchFilter({ ...searchFilter, name: !searchFilter.name }) setSearchFilter({ ...searchFilter, name: !searchFilter.name });
} }}
/> />
<span className="label-text">{t("name")}</span> <span className="label-text">Name</span>
</label> </label>
</li> </li>
<li> <li>
@@ -59,11 +54,11 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox" name="search-filter-checkbox"
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
checked={searchFilter.url} checked={searchFilter.url}
onChange={() => onChange={() => {
setSearchFilter({ ...searchFilter, url: !searchFilter.url }) setSearchFilter({ ...searchFilter, url: !searchFilter.url });
} }}
/> />
<span className="label-text">{t("link")}</span> <span className="label-text">Link</span>
</label> </label>
</li> </li>
<li> <li>
@@ -77,14 +72,35 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox" name="search-filter-checkbox"
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
checked={searchFilter.description} checked={searchFilter.description}
onChange={() => onChange={() => {
setSearchFilter({ setSearchFilter({
...searchFilter, ...searchFilter,
description: !searchFilter.description, description: !searchFilter.description,
}) });
} }}
/> />
<span className="label-text">{t("description")}</span> <span className="label-text">Description</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
</label> </label>
</li> </li>
<li> <li>
@@ -98,35 +114,14 @@ export default function FilterSearchDropdown({
name="search-filter-checkbox" name="search-filter-checkbox"
className="checkbox checkbox-primary" className="checkbox checkbox-primary"
checked={searchFilter.tags} checked={searchFilter.tags}
onChange={() => onChange={() => {
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
}
/>
<span className="label-text">{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={() =>
setSearchFilter({ setSearchFilter({
...searchFilter, ...searchFilter,
textContent: !searchFilter.textContent, tags: !searchFilter.tags,
}) });
} }}
/> />
<span className="label-text">{t("full_content")}</span> <span className="label-text">Tags</span>
<div className="ml-auto badge badge-sm badge-neutral">
{t("slower")}
</div>
</label> </label>
</li> </li>
</ul> </ul>
+17 -91
View File
@@ -1,31 +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;
}; };
export default function CollectionSelection({ export default function CollectionSelection({ onChange, defaultValue }: Props) {
onChange, const { collections } = useCollectionStore();
defaultValue,
showDefaultValue = true,
creatable = true,
}: Props) {
const { data: collections = [] } = useCollections();
const router = useRouter(); const router = useRouter();
const [options, setOptions] = useState<Options[]>([]); const [options, setOptions] = useState<Options[]>([]);
@@ -45,87 +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 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}
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}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
}}
// menuPosition="fixed"
/>
);
}
} }
+3 -3
View File
@@ -1,8 +1,8 @@
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;
@@ -13,12 +13,12 @@ type Props = {
}; };
export default function TagSelection({ onChange, defaultValue }: Props) { export default function TagSelection({ onChange, defaultValue }: Props) {
const { data: tags = [] } = useTags(); const { tags } = useTagStore();
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 };
}); });
-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 p-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;
-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>
);
}
@@ -5,6 +5,7 @@ import {
} from "@/types/global"; } from "@/types/global";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions"; import LinkActions from "@/components/LinkViews/LinkComponents/LinkActions";
import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate"; import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
@@ -12,58 +13,25 @@ import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection
import Image from "next/image"; import Image from "next/image";
import { previewAvailable } from "@/lib/shared/getArchiveValidity"; import { previewAvailable } from "@/lib/shared/getArchiveValidity";
import Link from "next/link"; import Link from "next/link";
import LinkIcon from "./LinkIcon"; import LinkIcon from "./LinkComponents/LinkIcon";
import LinkGroupedIconURL from "./LinkComponents/LinkGroupedIconURL";
import useOnScreen from "@/hooks/useOnScreen"; 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";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
className?: string; className?: string;
flipDropdown?: boolean;
editMode?: boolean;
}; };
export default function LinkMasonry({ link, flipDropdown, editMode }: Props) { export default function LinkGrid({ link, count, className }: Props) {
const { t } = useTranslation(); const { collections } = useCollectionStore();
const { data: collections = [] } = useCollections(); const { links, getLink } = useLinkStore();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
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; let shortendURL;
try { try {
if (link.url) { shortendURL = new URL(link.url || "").host.toLowerCase();
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@@ -85,7 +53,6 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isVisible = useOnScreen(ref); const isVisible = useOnScreen(ref);
const permissions = usePermissions(collection?.id as number);
useEffect(() => { useEffect(() => {
let interval: any; let interval: any;
@@ -96,7 +63,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
link.preview !== "unavailable" link.preview !== "unavailable"
) { ) {
interval = setInterval(async () => { interval = setInterval(async () => {
getLink.mutateAsync(link.id as number); getLink(link.id as number);
}, 5000); }, 5000);
} }
@@ -109,35 +76,17 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
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 ( return (
<div <div
ref={ref} ref={ref}
className={`${selectedStyle} border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative`} className="border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative"
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
> >
<div <Link
href={link.url || ""}
target="_blank"
className="rounded-2xl cursor-pointer" className="rounded-2xl cursor-pointer"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
> >
<div className="relative rounded-t-2xl overflow-hidden"> <div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? ( {previewAvailable(link) ? (
<Image <Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`} src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
@@ -145,69 +94,64 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
height={720} height={720}
alt="" alt=""
className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105" className="rounded-t-2xl select-none object-cover z-10 h-40 w-full shadow opacity-80 scale-105"
style={ style={{ filter: "blur(2px)" }}
link.type !== "image" ? { filter: "blur(1px)" } : undefined
}
draggable="false" draggable="false"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
target.style.display = "none"; target.style.display = "none";
}} }}
/> />
) : link.preview === "unavailable" ? null : ( ) : 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 className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)} )}
{link.type !== "image" && ( <div
<div className="absolute top-0 left-0 right-0 bottom-0 rounded-t-2xl flex items-center justify-center shadow rounded-md"> style={
<LinkIcon link={link} /> {
</div> // 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> </div>
{link.preview !== "unavailable" && ( <hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
<hr className="divider my-0 last:hidden border-t border-neutral-content h-[1px]" />
)}
<div className="p-3 flex flex-col gap-2"> <div className="p-3 mt-1">
<p className="hyphens-auto w-full pr-9 text-primary text-sm"> <p className="truncate w-full pr-8 text-primary">
{unescapeString(link.name)} {unescapeString(link.name || link.description) || link.url}
</p> </p>
<LinkTypeBadge link={link} /> <Link
href={link.url || ""}
{link.description && ( target="_blank"
<p className="hyphens-auto text-sm"> title={link.url || ""}
{unescapeString(link.description)} className="w-fit"
</p> >
)} <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>
{link.tags && link.tags[0] && ( <p className="text-sm truncate">{shortendURL}</p>
<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>
)} </Link>
</div> </div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> <hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
<div className="flex flex-wrap justify-between text-xs text-neutral px-3 pb-1 w-full gap-x-2"> <div className="flex justify-between text-xs text-neutral px-3 pb-1">
{collection && <LinkCollection link={link} collection={collection} />} <div className="cursor-pointer w-fit">
{collection ? (
<LinkCollection link={link} collection={collection} />
) : undefined}
</div>
<LinkDate link={link} /> <LinkDate link={link} />
</div> </div>
</div> </Link>
{showInfo && ( {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 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 <div
onClick={() => setShowInfo(!showInfo)} onClick={() => setShowInfo(!showInfo)}
@@ -215,9 +159,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
> >
<i className="bi-x text-neutral text-2xl"></i> <i className="bi-x text-neutral text-2xl"></i>
</div> </div>
<p className="text-neutral text-lg font-semibold"> <p className="text-neutral text-lg font-semibold">Description</p>
{t("description")}
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" /> <hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
<p> <p>
@@ -225,15 +167,13 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
unescapeString(link.description) unescapeString(link.description)
) : ( ) : (
<span className="text-neutral text-sm"> <span className="text-neutral text-sm">
{t("no_description")} No description provided.
</span> </span>
)} )}
</p> </p>
{link.tags && link.tags[0] && ( {link.tags[0] ? (
<> <>
<p className="text-neutral text-lg mt-3 font-semibold"> <p className="text-neutral text-lg mt-3 font-semibold">Tags</p>
{t("tags")}
</p>
<hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" /> <hr className="divider my-2 last:hidden border-t border-neutral-content h-[1px]" />
@@ -254,21 +194,16 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
</div> </div>
</div> </div>
</> </>
)} ) : undefined}
</div> </div>
)} ) : undefined}
<LinkActions <LinkActions
link={link} link={link}
collection={collection} collection={collection}
position={ position="top-[10.75rem] right-3"
link.preview !== "unavailable"
? "top-[10.75rem] right-3"
: "top-[.75rem] right-3"
}
toggleShowInfo={() => setShowInfo(!showInfo)} toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo} linkInfo={showInfo}
flipDropdown={flipDropdown}
/> />
</div> </div>
); );
@@ -7,11 +7,9 @@ import usePermissions from "@/hooks/usePermissions";
import EditLinkModal from "@/components/ModalContent/EditLinkModal"; import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal"; import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal"; import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import { dropdownTriggerer } from "@/lib/client/utils"; import useLinkStore from "@/store/links";
import { useTranslation } from "next-i18next"; import { toast } from "react-hot-toast";
import { useUser } from "@/hooks/store/user"; import useAccountStore from "@/store/account";
import { useDeleteLink, useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@@ -19,8 +17,6 @@ type Props = {
position?: string; position?: string;
toggleShowInfo?: () => void; toggleShowInfo?: () => void;
linkInfo?: boolean; linkInfo?: boolean;
alignToTop?: boolean;
flipDropdown?: boolean;
}; };
export default function LinkActions({ export default function LinkActions({
@@ -28,46 +24,41 @@ export default function LinkActions({
toggleShowInfo, toggleShowInfo,
position, position,
linkInfo, linkInfo,
alignToTop,
flipDropdown,
}: Props) { }: Props) {
const { t } = useTranslation();
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
const [editLinkModal, setEditLinkModal] = useState(false); const [editLinkModal, setEditLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false); const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false); const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const { data: user = {} } = useUser(); const { account } = useAccountStore();
const updateLink = useUpdateLink(); const { removeLink, updateLink } = useLinkStore();
const deleteLink = useDeleteLink();
const pinLink = async () => { const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false; const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
const load = toast.loading(t("updating")); const load = toast.loading("Applying...");
await updateLink.mutateAsync( const response = await updateLink({
{ ...link,
...link, pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }], });
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) { toast.dismiss(load);
toast.error(error.message);
} else { response.ok &&
toast.success( toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
isAlreadyPinned ? t("link_unpinned") : t("link_pinned") };
);
} const deleteLink = async () => {
}, const load = toast.loading("Deleting...");
}
); const response = await removeLink(link.id as number);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
}; };
return ( return (
@@ -75,35 +66,32 @@ export default function LinkActions({
<div <div
className={`dropdown dropdown-left absolute ${ className={`dropdown dropdown-left absolute ${
position || "top-3 right-3" position || "top-3 right-3"
} ${alignToTop ? "" : "dropdown-end"} z-20`} } z-20`}
> >
<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 title="More" className="bi-three-dots text-xl" /> <i title="More" className="bi-three-dots text-xl" />
</div> </div>
<ul <ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 ${ {permissions === true ? (
alignToTop ? "" : "translate-y-10" <li>
}`} <div
> role="button"
<li> tabIndex={0}
<div onClick={() => {
role="button" (document?.activeElement as HTMLElement)?.blur();
tabIndex={0} pinLink();
onClick={() => { }}
(document?.activeElement as HTMLElement)?.blur(); >
pinLink(); {link?.pinnedBy && link.pinnedBy[0]
}} ? "Unpin"
> : "Pin to Dashboard"}
{link?.pinnedBy && link.pinnedBy[0] </div>
? t("unpin") </li>
: t("pin_to_dashboard")} ) : undefined}
</div>
</li>
{linkInfo !== undefined && toggleShowInfo ? ( {linkInfo !== undefined && toggleShowInfo ? (
<li> <li>
<div <div
@@ -114,7 +102,7 @@ export default function LinkActions({
toggleShowInfo(); toggleShowInfo();
}} }}
> >
{!linkInfo ? t("show_link_details") : t("hide_link_details")} {!linkInfo ? "Show" : "Hide"} Link Details
</div> </div>
</li> </li>
) : undefined} ) : undefined}
@@ -128,51 +116,33 @@ export default function LinkActions({
setEditLinkModal(true); setEditLinkModal(true);
}} }}
> >
{t("edit_link")} Edit Link
</div> </div>
</li> </li>
) : undefined} ) : undefined}
{link.type === "url" && ( <li>
<li> <div
<div role="button"
role="button" tabIndex={0}
tabIndex={0} onClick={() => {
onClick={() => { (document?.activeElement as HTMLElement)?.blur();
(document?.activeElement as HTMLElement)?.blur(); setPreservedFormatsModal(true);
setPreservedFormatsModal(true); }}
}} >
> Preserved Formats
{t("preserved_formats")} </div>
</div> </li>
</li>
)}
{permissions === true || permissions?.canDelete ? ( {permissions === true || permissions?.canDelete ? (
<li> <li>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={async (e) => { onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
e.shiftKey e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
? async () => {
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"));
}
},
});
}
: setDeleteLinkModal(true);
}} }}
> >
{t("delete")} Delete
</div> </div>
</li> </li>
) : undefined} ) : undefined}
@@ -194,7 +164,7 @@ export default function LinkActions({
{preservedFormatsModal ? ( {preservedFormatsModal ? (
<PreservedFormatsModal <PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)} onClose={() => setPreservedFormatsModal(false)}
link={link} activeLink={link}
/> />
) : undefined} ) : undefined}
{/* {expandedLink ? ( {/* {expandedLink ? (
@@ -1,260 +0,0 @@
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useEffect, 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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCard({ link, flipDropdown, editMode }: Props) {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
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);
useEffect(() => {
let interval: any;
if (
isVisible &&
!link.preview?.startsWith("archives") &&
link.preview !== "unavailable"
) {
interval = setInterval(async () => {
getLink.mutateAsync(link.id as number);
}, 5000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [isVisible, link.preview]);
const [showInfo, setShowInfo] = useState(false);
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`}
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")
}
>
<div>
<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={
link.type !== "image" ? { 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 duration-100 h-40 bg-opacity-80"></div>
) : (
<div className="duration-100 h-40 bg-opacity-80 skeleton rounded-none"></div>
)}
{link.type !== "image" && (
<div 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 border-t border-neutral-content h-[1px]" />
</div>
<div className="flex flex-col justify-between h-full">
<div className="p-3 flex flex-col gap-2">
<p className="truncate w-full pr-9 text-primary text-sm">
{unescapeString(link.name)}
</p>
<LinkTypeBadge link={link} />
</div>
<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 gap-2">
<div className="cursor-pointer truncate">
{collection && (
<LinkCollection link={link} collection={collection} />
)}
</div>
<LinkDate link={link} />
</div>
</div>
</div>
</div>
{showInfo && (
<div className="p-3 absolute z-30 top-0 left-0 right-0 bottom-0 bg-base-200 rounded-[0.9rem] 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">
{t("description")}
</p>
<hr className="divider my-2 border-t border-neutral-content h-[1px]" />
<p>
{link.description ? (
unescapeString(link.description)
) : (
<span className="text-neutral text-sm">
{t("no_description")}
</span>
)}
</p>
{link.tags && link.tags[0] && (
<>
<p className="text-neutral text-lg mt-3 font-semibold">
{t("tags")}
</p>
<hr className="divider my-2 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>
</>
)}
</div>
)}
<LinkActions
link={link}
collection={collection}
position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo}
flipDropdown={flipDropdown}
/>
</div>
);
}
@@ -2,7 +2,7 @@ import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import Link from "next/link"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
export default function LinkCollection({ export default function LinkCollection({
@@ -12,22 +12,22 @@ export default function LinkCollection({
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
}) { }) {
const router = useRouter();
return ( return (
<> <div
<Link onClick={(e) => {
href={`/collections/${link.collection.id}`} e.preventDefault();
onClick={(e) => { router.push(`/collections/${link.collection.id}`);
e.stopPropagation(); }}
}} className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none" title={collection?.name}
title={collection?.name} >
> <i
<i className="bi-folder-fill text-lg drop-shadow"
className="bi-folder-fill text-lg drop-shadow" style={{ color: collection?.color }}
style={{ color: collection?.color }} ></i>
></i> <p className="truncate capitalize">{collection?.name}</p>
<p className="truncate capitalize">{collection?.name}</p> </div>
</Link>
</>
); );
} }
@@ -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>
);
}
@@ -5,99 +5,44 @@ import React from "react";
export default function LinkIcon({ export default function LinkIcon({
link, link,
className, width,
size,
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
className?: string; width?: string;
size?: "small" | "medium";
}) { }) {
let iconClasses: string =
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10 " +
(className || "");
let dimension;
switch (size) {
case "small":
dimension = " w-8 h-8";
break;
case "medium":
dimension = " w-12 h-12";
break;
default:
size = "medium";
dimension = " w-12 h-12";
break;
}
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 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); const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return ( return (
<> <>
{link.type === "url" && url ? ( {link.url && url && showFavicon ? (
showFavicon ? ( <Image
<Image src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`} width={64}
width={64} height={64}
height={64} alt=""
alt="" className={iconClasses}
className={iconClasses + dimension} draggable="false"
draggable="false" onError={() => {
onError={() => { setShowFavicon(false);
setShowFavicon(false); }}
}} />
/> ) : showFavicon === false ? (
) : ( <div className={iconClasses}>
<LinkPlaceholderIcon <i className="bi-link-45deg text-4xl text-black"></i>
iconClasses={iconClasses + dimension} </div>
size={size}
icon="bi-link-45deg"
/>
)
) : link.type === "pdf" ? ( ) : link.type === "pdf" ? (
<LinkPlaceholderIcon <i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
iconClasses={iconClasses + dimension}
size={size}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? ( ) : link.type === "image" ? (
<LinkPlaceholderIcon <i className={`bi-file-earmark-image ${iconClasses}`}></i>
iconClasses={iconClasses + dimension} ) : undefined}
size={size}
icon="bi-file-earmark-image"
/>
) : // : link.type === "monolith" ? (
// <LinkPlaceholderIcon
// iconClasses={iconClasses + dimension}
// size={size}
// icon="bi-filetype-html"
// />
// )
undefined}
</> </>
); );
} }
const LinkPlaceholderIcon = ({
iconClasses,
size,
icon,
}: {
iconClasses: string;
size?: "small" | "medium";
icon: string;
}) => {
return (
<div
className={`${
size === "small" ? "text-2xl" : "text-4xl"
} text-black aspect-square ${iconClasses}`}
>
<i className={`${icon} m-auto`}></i>
</div>
);
};
@@ -1,172 +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";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
count: number;
className?: string;
flipDropdown?: boolean;
editMode?: boolean;
};
export default function LinkCardCompact({
link,
flipDropdown,
editMode,
}: Props) {
const { t } = useTranslation();
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const { setSelectedLinks, selectedLinks } = useLinkStore();
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 [showInfo, setShowInfo] = useState(false);
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);
return (
<>
<div
className={`${selectedStyle} border relative items-center flex ${
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3"
} duration-200 rounded-lg w-full`}
onClick={() =>
selectable
? handleCheckboxClick(link)
: editMode
? toast.error(t("link_selection_error"))
: undefined
}
>
{/* {showCheckbox &&
editMode &&
(permissions === true ||
permissions?.canCreate ||
permissions?.canDelete) && (
<input
type="checkbox"
className="checkbox checkbox-primary my-auto mr-2"
checked={selectedLinks.some(
(selectedLink) => selectedLink.id === link.id
)}
onChange={() => handleCheckboxClick(link)}
/>
)} */}
<div
className="flex items-center cursor-pointer w-full"
onClick={() =>
!editMode && window.open(generateLinkHref(link, user), "_blank")
}
>
<div className="shrink-0">
<LinkIcon link={link} className="w-12 h-12 text-4xl" />
</div>
<div className="w-[calc(100%-56px)] ml-2">
<p className="line-clamp-1 mr-8 text-primary select-none">
{link.name ? (
unescapeString(link.name)
) : (
<div className="mt-2">
<LinkTypeBadge link={link} />
</div>
)}
</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">
{collection ? (
<LinkCollection link={link} collection={collection} />
) : undefined}
{link.name && <LinkTypeBadge link={link} />}
<LinkDate link={link} />
</div>
</div>
</div>
</div>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo}
/>
</div>
<div
className="last:hidden rounded-none"
style={{
borderTop: "1px solid var(--fallback-bc,oklch(var(--bc)/0.1))",
}}
></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>
</>
);
}
-238
View File
@@ -1,238 +0,0 @@
import LinkCard from "@/components/LinkViews/LinkComponents/LinkCard";
import {
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@/types/global";
import { useEffect } 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";
export function CardView({
links,
editMode,
isLoading,
placeholders,
hasNextPage,
placeHolderRef,
}: {
links?: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean;
isLoading?: boolean;
placeholders?: number[];
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
return (
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
{links?.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(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 fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
return {
default: 5,
1900: 4,
1500: 3,
880: 2,
550: 1,
};
}, []);
return (
<Masonry
breakpointCols={breakpointColumnsObj}
columnClassName="flex flex-col gap-5 !w-full"
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
>
{links?.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(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 gap-1 flex-col">
{links?.map((e, i) => {
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
placeholders?.map((e, i) => {
return (
<div
ref={e === 1 ? placeHolderRef : undefined}
key={i}
className="flex gap-4 p-4"
>
<div className="skeleton h-16 w-16"></div>
<div className="flex flex-col gap-4 w-full">
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 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>
);
}
-98
View File
@@ -1,98 +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 w-40 mb-1 -ml-12">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
>
{t("new_link")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
{t("upload_file")}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
>
{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)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
</>
);
}
-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 -77
View File
@@ -1,93 +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 && setTimeout(() => toggleModal(), 100)} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
dismissible={dismissible} className || ""
}`}
> >
<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" />
<ClickAwayHandler
onClickOutside={() => dismissible && setDrawerIsOpen(false)}
>
<Drawer.Content className="flex flex-col rounded-t-2xl min-h-max 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>
</ClickAwayHandler>
</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,113 +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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setSelectedLinks([]);
onClose();
toast.success(t("updated"));
}
},
}
);
setSubmitLoader(false);
}
};
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,43 +16,42 @@ 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) => {
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); setSubmitLoader(false);
} }
@@ -63,7 +60,7 @@ export default function DeleteCollectionModal({
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>
@@ -71,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,55 +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";
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();
},
});
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">{t("delete_user")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("confirm_user_deletion")}</p>
<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" />
{t("delete_confirmation")}
</Button>
</div>
</Modal>
);
}
+31 -34
View File
@@ -1,11 +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 { 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";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -16,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) {
@@ -30,20 +28,18 @@ 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) => {
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); setSubmitLoader(false);
} }
@@ -51,35 +47,29 @@ export default function EditCollectionModal({
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 sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="w-full"> <div className="w-full">
<p className="mb-2">{t("name")}</p> <p className="mb-2">Name</p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<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> <div>
<p className="w-full mb-2">{t("color")}</p> <p className="w-full mb-2">Color</p>
<div className="color-picker flex justify-between items-center"> <div className="color-picker flex justify-between">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32"> <div className="flex flex-col gap-2 items-center w-32">
<i <i
className="bi-folder-fill text-5xl" className="bi-folder-fill text-5xl drop-shadow"
style={{ color: collection.color }} style={{ color: collection.color }}
></i> ></i>
<div <div
@@ -88,22 +78,29 @@ export default function EditCollectionModal({
setCollection({ ...collection, color: "#0ea5e9" }) setCollection({ ...collection, color: "#0ea5e9" })
} }
> >
{t("reset")} Reset
</div> </div>
</div> </div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div> </div>
</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-[13rem] 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>
@@ -113,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,16 +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 { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global"; import { 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";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -21,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) {
@@ -36,26 +32,24 @@ 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) => {
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); 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);
@@ -70,7 +64,6 @@ export default function EditCollectionSharingModal({
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean, archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean, archiveAsPDF: undefined as unknown as boolean,
}); });
@@ -99,7 +92,7 @@ export default function EditCollectionSharingModal({
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin"> <p className="text-xl font-thin">
{permissions === true ? t("share_and_collaborate") : t("team")} {permissions === true ? "Share and Collaborate" : "Team"}
</p> </p>
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
@@ -107,7 +100,7 @@ export default function EditCollectionSharingModal({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{permissions === true && ( {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
@@ -121,26 +114,25 @@ 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 className={permissions === true ? "pl-5" : ""}> <div className={permissions === true ? "pl-5" : ""}>
<p className="mb-2">{t("sharable_link_guide")}</p> <p className="mb-2">Sharable Link (Click to copy)</p>
<div <div
onClick={() => { onClick={() => {
try { try {
navigator.clipboard navigator.clipboard
.writeText(publicCollectionURL) .writeText(publicCollectionURL)
.then(() => toast.success(t("copied"))); .then(() => toast.success("Copied!"));
} catch (err) { } catch (err) {
console.log(err); console.log(err);
} }
@@ -156,22 +148,21 @@ export default function EditCollectionSharingModal({
{permissions === true && ( {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={memberUsername || ""} value={memberUsername || ""}
className="bg-base-200" className="bg-base-200"
placeholder={t("members_username_placeholder")} placeholder="Username (without the '@')"
onChange={(e) => setMemberUsername(e.target.value)} onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) => onKeyDown={(e) =>
e.key === "Enter" && e.key === "Enter" &&
addMemberToCollection( addMemberToCollection(
user.username as string, account.username as string,
memberUsername || "", memberUsername || "",
collection, collection,
setMemberState, setMemberState
t
) )
} }
/> />
@@ -179,11 +170,10 @@ export default function EditCollectionSharingModal({
<div <div
onClick={() => onClick={() =>
addMemberToCollection( addMemberToCollection(
user.username as string, account.username as string,
memberUsername || "", 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"
@@ -223,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>
@@ -235,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"}
> >
@@ -271,7 +264,6 @@ export default function EditCollectionSharingModal({
<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}
@@ -316,10 +308,8 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold"> <p className="font-bold">Viewer</p>
{t("viewer")} <p>Read-only access</p>
</p>
<p>{t("viewer_desc")}</p>
</div> </div>
</label> </label>
</li> </li>
@@ -361,10 +351,8 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold"> <p className="font-bold">Contributor</p>
{t("contributor")} <p>Can view and create Links</p>
</p>
<p>{t("contributor_desc")}</p>
</div> </div>
</label> </label>
</li> </li>
@@ -406,10 +394,8 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<div> <div>
<p className="font-bold"> <p className="font-bold">Admin</p>
{t("admin")} <p>Full access to all Links</p>
</p>
<p>{t("admin_desc")}</p>
</div> </div>
</label> </label>
</li> </li>
@@ -426,7 +412,7 @@ export default function EditCollectionSharingModal({
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) => {
@@ -445,7 +431,7 @@ 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>
@@ -457,7 +443,7 @@ export default function EditCollectionSharingModal({
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>
+48 -37
View File
@@ -3,12 +3,11 @@ 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 useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
import Modal from "../Modal"; import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -16,23 +15,23 @@ type Props = {
}; };
export default function EditLinkModal({ onClose, activeLink }: Props) { export default function EditLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink); useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
let shortenedURL; let shortendURL;
try { try {
shortenedURL = new URL(link.url || "").host.toLowerCase(); shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
const { updateLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const updateLink = useUpdateLink();
const setCollection = (e: any) => { const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null; if (e?.__isNew__) e.value = null;
setLink({ setLink({
...link, ...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId }, collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
@@ -40,7 +39,10 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
}; };
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 });
}; };
@@ -52,28 +54,28 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
if (!submitLoader) { if (!submitLoader) {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading(t("updating")); let response;
await updateLink.mutateAsync(link, { const load = toast.loading("Updating...");
onSettled: (data, error) => {
toast.dismiss(load);
if (error) { response = await updateLink(link);
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); setSubmitLoader(false);
return response;
} }
}; };
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("edit_link")}</p> <p className="text-xl font-thin">Edit Link</p>
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
@@ -85,56 +87,65 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
target="_blank" target="_blank"
> >
<i className="bi-link-45deg text-xl" /> <i className="bi-link-45deg text-xl" />
<p>{shortenedURL}</p> <p>{shortendURL}</p>
</Link> </Link>
) : undefined} ) : undefined}
<div className="w-full"> <div className="w-full">
<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("placeholder_example_link")} placeholder="e.g. Example Link"
className="bg-base-200" className="bg-base-200"
/> />
</div> </div>
<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("collection")}</p> <p className="mb-2">Collection</p>
{link.collection.name ? ( {link.collection.name ? (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={ defaultValue={
link.collection.id link.collection.id
? { value: link.collection.id, label: link.collection.name } ? {
: { value: null as unknown as number, label: "Unorganized" } value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
} }
creatable={false}
/> />
) : null} ) : null}
</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) as string} 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 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" 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>
@@ -146,7 +157,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
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("save_changes")} Save
</button> </button>
</div> </div>
</Modal> </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>
);
}
+30 -49
View File
@@ -1,26 +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 { 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";
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);
@@ -29,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;
@@ -38,61 +32,41 @@ 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);
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); 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 sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="w-full"> <div className="w-full">
<p className="mb-2">{t("name")}</p> <p className="mb-2">Name</p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-3">
<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> <div>
<p className="w-full mb-2">{t("color")}</p> <p className="w-full mb-2">Color</p>
<div className="color-picker flex justify-between items-center"> <div className="color-picker flex justify-between">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32"> <div className="flex flex-col gap-2 items-center w-32">
<i <i
className={"bi-folder-fill text-5xl"} className={"bi-folder-fill text-5xl"}
@@ -104,22 +78,29 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
setCollection({ ...collection, color: "#0ea5e9" }) setCollection({ ...collection, color: "#0ea5e9" })
} }
> >
{t("reset")} Reset
</div> </div>
</div> </div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div> </div>
</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-[13rem] 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>
@@ -129,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>
+61 -40
View File
@@ -1,24 +1,24 @@
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 { LinkIncludingShortenedCollectionAndTags } 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 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 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 { data } = useSession();
const initial = { const initial = {
name: "", name: "",
url: "", url: "",
@@ -29,7 +29,6 @@ export default function NewLinkModal({ onClose }: Props) {
image: "", image: "",
pdf: "", pdf: "",
readable: "", readable: "",
monolith: "",
textContent: "", textContent: "",
collection: { collection: {
name: "", name: "",
@@ -40,15 +39,17 @@ export default function NewLinkModal({ onClose }: Props) {
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial); 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 = null; if (e?.__isNew__) e.value = null;
setLink({ setLink({
...link, ...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId }, collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
@@ -56,7 +57,10 @@ export default function NewLinkModal({ onClose }: Props) {
}; };
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 });
}; };
@@ -65,6 +69,7 @@ export default function NewLinkModal({ onClose }: Props) {
const currentCollection = collections.find( const currentCollection = collections.find(
(e) => e.id == Number(router.query.id) (e) => e.id == Number(router.query.id)
); );
if ( if (
currentCollection && currentCollection &&
currentCollection.ownerId && currentCollection.ownerId &&
@@ -81,7 +86,10 @@ export default function NewLinkModal({ onClose }: Props) {
} else } else
setLink({ setLink({
...initial, ...initial,
collection: { name: "Unorganized", ownerId: data?.user.id as number }, collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
}); });
}, []); }, []);
@@ -89,41 +97,43 @@ 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) => {
toast.dismiss(load);
if (error) { response = await addLink(link);
toast.error(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); 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}
@@ -135,57 +145,68 @@ export default function NewLinkModal({ onClose }: Props) {
) : null} ) : 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) as string} 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 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} ) : 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>
-240
View File
@@ -1,240 +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";
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) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
setNewToken(data.secretKey);
}
},
});
setSubmitLoader(false);
}
};
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>
<TextInput
spellCheck={false}
value={newToken}
onChange={() => {}}
className="w-full"
/>
<button
onClick={() => {
navigator.clipboard.writeText(newToken);
toast.success(t("copied_to_clipboard"));
}}
className="btn btn-primary w-fit mx-auto"
>
{t("copy_to_clipboard")}
</button>
</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 w-full sm:w-52 mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
checked={token.expires === TokenExpiry.sevenDays}
onChange={() => {
(document?.activeElement as HTMLElement)?.blur();
setToken({
...token,
expires: TokenExpiry.sevenDays,
});
}}
/>
<span className="label-text">{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">{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">{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">{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">{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>
);
}
-141
View File
@@ -1,141 +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) {
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();
},
});
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>
) : undefined}
<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>
);
}
+107 -121
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import { import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat, ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Link from "next/link"; import Link from "next/link";
@@ -11,26 +12,26 @@ import { useSession } from "next-auth/react";
import { import {
pdfAvailable, pdfAvailable,
readabilityAvailable, readabilityAvailable,
monolithAvailable,
screenshotAvailable, screenshotAvailable,
} from "@/lib/shared/getArchiveValidity"; } from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow"; import PreservedFormatRow from "@/components/PreserverdFormatRow";
import useAccountStore from "@/store/account";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import { useGetLink } from "@/hooks/store/links";
type Props = { type Props = {
onClose: Function; onClose: Function;
link: LinkIncludingShortenedCollectionAndTags; activeLink: LinkIncludingShortenedCollectionAndTags;
}; };
export default function PreservedFormatsModal({ onClose, link }: Props) { export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const session = useSession(); const session = useSession();
const getLink = useGetLink(); const { getLink } = useLinkStore();
const { data: user = {} } = useUser();
const { account } = useAccountStore();
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;
@@ -41,26 +42,24 @@ export default function PreservedFormatsModal({ onClose, link }: Props) {
username: "", username: "",
image: "", image: "",
archiveAsScreenshot: undefined as unknown as boolean, archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean, archiveAsPDF: undefined as unknown as boolean,
}); });
useEffect(() => { useEffect(() => {
const fetchOwner = async () => { const fetchOwner = async () => {
if (link.collection.ownerId !== user.id) { if (link.collection.ownerId !== account.id) {
const owner = await getPublicUserData( const owner = await getPublicUserData(
link.collection.ownerId as number link.collection.ownerId as number
); );
setCollectionOwner(owner); setCollectionOwner(owner);
} else if (link.collection.ownerId === user.id) { } else if (link.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.archiveAsScreenshot as boolean, archiveAsPDF: account.archiveAsPDF as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
}); });
} }
}; };
@@ -70,40 +69,32 @@ export default function PreservedFormatsModal({ onClose, link }: Props) {
const isReady = () => { const isReady = () => {
return ( return (
collectionOwner.archiveAsScreenshot ===
(link && link.pdf && link.pdf !== "pending") &&
collectionOwner.archiveAsPDF ===
(link && link.pdf && link.pdf !== "pending") &&
link && 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 &&
link.readable !== "pending" link.readable !== "pending"
); );
}; };
const atLeastOneFormatAvailable = () => {
return (
screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link)
);
};
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await getLink.mutateAsync(link.id as number); const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
})(); })();
let interval: any; let interval: any;
if (!isReady()) { if (!isReady()) {
interval = setInterval(async () => { interval = setInterval(async () => {
await getLink.mutateAsync(link.id as number); const data = await getLink(link.id as number, isPublic);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
}, 5000); }, 5000);
} else { } else {
if (interval) { if (interval) {
@@ -116,104 +107,94 @@ export default function PreservedFormatsModal({ onClose, link }: Props) {
clearInterval(interval); clearInterval(interval);
} }
}; };
}, [link?.monolith]); }, [link?.image, link?.pdf, link?.readable]);
const updateArchive = async () => { const updateArchive = async () => {
const load = toast.loading(t("sending_request")); const load = toast.loading("Sending request...");
const response = await fetch(`/api/v1/links/${link?.id}/archive`, { const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT", method: "PUT",
}); });
const data = await response.json(); const data = await response.json();
toast.dismiss(load); toast.dismiss(load);
if (response.ok) { if (response.ok) {
await getLink.mutateAsync(link?.id as number); const newLink = await getLink(link?.id as number);
setLink(
toast.success(t("link_being_archived")); (newLink as any).response as LinkIncludingShortenedCollectionAndTags
);
toast.success(`Link is being archived...`);
} else toast.error(data.response); } else toast.error(data.response);
}; };
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("preserved_formats")}</p> <p className="text-xl font-thin">Preserved Formats</p>
<div className="divider mb-2 mt-1"></div> <div className="divider mb-2 mt-1"></div>
{screenshotAvailable(link) ||
pdfAvailable(link) || {isReady() &&
readabilityAvailable(link) || (screenshotAvailable(link) ||
monolithAvailable(link) ? ( pdfAvailable(link) ||
<p className="mb-3">{t("available_formats")}</p> readabilityAvailable(link)) ? (
<p className="mb-3">
The following formats are available for this link:
</p>
) : ( ) : (
"" ""
)} )}
<div className={`flex flex-col gap-3`}> <div className={`flex flex-col gap-3`}>
{monolithAvailable(link) ? ( {isReady() ? (
<PreservedFormatRow <>
name={t("webpage")} {screenshotAvailable(link) ? (
icon={"bi-filetype-html"} <PreservedFormatRow
format={ArchivedFormat.monolith} name={"Screenshot"}
link={link} icon={"bi-file-earmark-image"}
downloadable={true} format={
/> link?.image?.endsWith("png")
) : undefined} ? ArchivedFormat.png
: ArchivedFormat.jpeg
}
activeLink={link}
downloadable={true}
/>
) : undefined}
{screenshotAvailable(link) ? ( {pdfAvailable(link) ? (
<PreservedFormatRow <PreservedFormatRow
name={t("screenshot")} name={"PDF"}
icon={"bi-file-earmark-image"} icon={"bi-file-earmark-pdf"}
format={ format={ArchivedFormat.pdf}
link?.image?.endsWith("png") activeLink={link}
? ArchivedFormat.png downloadable={true}
: ArchivedFormat.jpeg />
} ) : undefined}
link={link}
downloadable={true}
/>
) : undefined}
{pdfAvailable(link) ? ( {readabilityAvailable(link) ? (
<PreservedFormatRow <PreservedFormatRow
name={t("pdf")} name={"Readable"}
icon={"bi-file-earmark-pdf"} icon={"bi-file-earmark-text"}
format={ArchivedFormat.pdf} format={ArchivedFormat.readability}
link={link} activeLink={link}
downloadable={true} />
/> ) : undefined}
) : undefined} </>
) : (
{readabilityAvailable(link) ? ( <div
<PreservedFormatRow className={`w-full h-full flex flex-col justify-center p-10 skeleton bg-base-200`}
name={t("readable")} >
icon={"bi-file-earmark-text"} <i className="bi-stack drop-shadow text-primary text-8xl mx-auto mb-5"></i>
format={ArchivedFormat.readability} <p className="text-center text-2xl">
link={link} Link preservation is in the queue
/> </p>
) : undefined} <p className="text-center text-lg">
Please check back later to see the result
{!isReady() && !atLeastOneFormatAvailable() ? ( </p>
<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> </div>
) : !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}
<div <div
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${ className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
@@ -226,21 +207,26 @@ export default function PreservedFormatsModal({ onClose, link }: Props) {
"" ""
)}`} )}`}
target="_blank" target="_blank"
className="text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm" className={`text-neutral 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> <p className="whitespace-nowrap">
View latest snapshot on archive.org
</p>
<i className="bi-box-arrow-up-right" /> <i className="bi-box-arrow-up-right" />
</Link> </Link>
{link?.collection.ownerId === session.data?.user.id && ( {link?.collection.ownerId === session.data?.user.id ? (
<div className="btn btn-outline" onClick={updateArchive}> <div
className={`btn w-1/2 btn-outline`}
onClick={() => updateArchive()}
>
<div> <div>
<p>{t("refresh_preserved_formats")}</p> <p>Refresh Preserved Formats</p>
<p className="text-xs"> <p className="text-xs">
{t("this_deletes_current_preservations")} This deletes the current preservations
</p> </p>
</div> </div>
</div> </div>
)} ) : undefined}
</div> </div>
</div> </div>
</Modal> </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>
);
}
+66 -51
View File
@@ -3,24 +3,22 @@ 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";
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 = {
@@ -33,7 +31,6 @@ export default function UploadFileModal({ onClose }: Props) {
image: "", image: "",
pdf: "", pdf: "",
readable: "", readable: "",
monolith: "",
textContent: "", textContent: "",
collection: { collection: {
name: "", name: "",
@@ -43,13 +40,16 @@ export default function UploadFileModal({ onClose }: Props) {
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial); useState<LinkIncludingShortenedCollectionAndTags>(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 = null; if (e?.__isNew__) e.value = null;
@@ -74,6 +74,7 @@ export default function UploadFileModal({ onClose }: Props) {
const currentCollection = collections.find( const currentCollection = collections.find(
(e) => e.id == Number(router.query.id) (e) => e.id == Number(router.query.id)
); );
if ( if (
currentCollection && currentCollection &&
currentCollection.ownerId && currentCollection.ownerId &&
@@ -90,14 +91,17 @@ export default function UploadFileModal({ onClose }: Props) {
} else } else
setLink({ setLink({
...initial, ...initial,
collection: { name: "Unorganized", ownerId: data?.user.id as number }, 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;
@@ -109,60 +113,70 @@ 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) => {
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); 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"
accept=".pdf,.png,.jpg,.jpeg,.html" accept=".pdf,.png,.jpg,.jpeg"
className="cursor-pointer custom-file-input" className="cursor-pointer custom-file-input"
onChange={(e) => e.target.files && setFile(e.target.files[0])} onChange={(e) => e.target.files && setFile(e.target.files[0])}
/> />
</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}
@@ -176,34 +190,36 @@ export default function UploadFileModal({ onClose }: Props) {
</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) => {
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) as string} 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 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" 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>
@@ -215,15 +231,14 @@ export default function UploadFileModal({ onClose }: Props) {
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>
+76 -26
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>
@@ -76,10 +86,10 @@ export default function Navbar() {
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
{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,9 +98,9 @@ export default function Navbar() {
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
{t("upload_file")} Upload File
</div> </div>
</li> </li> */}
<li> <li>
<div <div
onClick={() => { onClick={() => {
@@ -100,17 +110,57 @@ export default function Navbar() {
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
{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>
<MobileNavigation />
{sidebar ? ( {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}>
+4 -4
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,7 +35,7 @@ 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>
+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>
+44 -13
View File
@@ -1,16 +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 { useGetLink } from "@/hooks/store/links"; 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;
}; };
@@ -18,30 +21,58 @@ export default function PreservedFormatRow({
name, name,
icon, icon,
format, format,
link, activeLink,
downloadable, downloadable,
}: Props) { }: Props) {
const getLink = useGetLink(); 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");
} }
-90
View File
@@ -1,90 +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 ${
isAdmin ? "w-48" : "w-40"
} mt-1`}
>
<li>
<Link
href="/settings/account"
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
tabIndex={0}
role="button"
>
{t("settings")}
</Link>
</li>
<li className="block sm:hidden">
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
>
{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"
>
{t("server_administration")}
</Link>
</li>
) : null}
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
{t("logout")}
</div>
</li>
</ul>
</div>
);
}
+2 -2
View File
@@ -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>
); );
+17 -43
View File
@@ -1,9 +1,9 @@
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,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import ColorThief, { RGBColor } from "colorthief"; import ColorThief, { RGBColor } from "colorthief";
@@ -11,11 +11,7 @@ import DOMPurify from "dompurify";
import Image from "next/image"; 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, useMemo, useState } from "react"; import React, { useEffect, useState } from "react";
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useGetLink } from "@/hooks/store/links";
type LinkContent = { type LinkContent = {
title: string; title: string;
@@ -34,25 +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();
const { data: collections = [] } = useCollections();
const collection = useMemo(() => {
return collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount;
}, [collections, link]);
useEffect(() => { useEffect(() => {
const fetchLinkContent = async () => { const fetchLinkContent = async () => {
@@ -68,12 +54,10 @@ export default function ReadableView({ link }: Props) {
}; };
fetchLinkContent(); fetchLinkContent();
setDate(link.importDate || link.createdAt);
}, [link]); }, [link]);
useEffect(() => { useEffect(() => {
if (link) getLink.mutateAsync(link?.id as number); if (link) getLink(link?.id as number);
let interval: any; let interval: any;
if ( if (
@@ -81,16 +65,11 @@ export default function ReadableView({ link }: Props) {
(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(link.id as number),
5000
);
} else { } else {
if (interval) { if (interval) {
clearInterval(interval); clearInterval(interval);
@@ -102,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 =>
"#" + "#" +
@@ -145,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>
@@ -181,7 +160,7 @@ 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 || ""
)} )}
@@ -219,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}
@@ -232,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",
@@ -243,13 +222,6 @@ export default function ReadableView({ link }: Props) {
{link?.name ? <p>{unescapeString(link?.description)}</p> : undefined} {link?.name ? <p>{unescapeString(link?.description)}</p> : undefined}
</div> </div>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
alignToTop
/>
</div> </div>
<div className="flex flex-col gap-5 h-full"> <div className="flex flex-col gap-5 h-full">
@@ -278,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>
+45 -24
View File
@@ -1,18 +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";
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 { 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
@@ -24,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 && ( {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">
@@ -95,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>
)} )}
+18 -21
View File
@@ -1,29 +1,18 @@
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";
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();
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>
@@ -38,10 +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={() => setSort(Sort.DateNewestFirst)} onChange={() => {
setSort(Sort.DateNewestFirst);
}}
/> />
<span className="label-text">{t("date_newest_first")}</span> <span className="label-text">Date (Newest First)</span>
</label> </label>
</li> </li>
<li> <li>
@@ -54,10 +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={() => setSort(Sort.DateOldestFirst)} onChange={() => setSort(Sort.DateOldestFirst)}
/> />
<span className="label-text">{t("date_oldest_first")}</span> <span className="label-text">Date (Oldest First)</span>
</label> </label>
</li> </li>
<li> <li>
@@ -70,10 +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={() => setSort(Sort.NameAZ)} onChange={() => setSort(Sort.NameAZ)}
/> />
<span className="label-text">{t("name_az")}</span> <span className="label-text">Name (A-Z)</span>
</label> </label>
</li> </li>
<li> <li>
@@ -86,10 +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={() => setSort(Sort.NameZA)} onChange={() => setSort(Sort.NameZA)}
/> />
<span className="label-text">{t("name_za")}</span> <span className="label-text">Name (Z-A)</span>
</label> </label>
</li> </li>
<li> <li>
@@ -102,10 +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={() => setSort(Sort.DescriptionAZ)} onChange={() => setSort(Sort.DescriptionAZ)}
/> />
<span className="label-text">{t("description_az")}</span> <span className="label-text">Description (A-Z)</span>
</label> </label>
</li> </li>
<li> <li>
@@ -118,10 +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={() => setSort(Sort.DescriptionZA)} onChange={() => setSort(Sort.DescriptionZA)}
/> />
<span className="label-text">{t("description_za")}</span> <span className="label-text">Description (Z-A)</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}
+35 -32
View File
@@ -1,44 +1,47 @@
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
type Props = { type Props = {
className?: string; className?: string;
}; };
export default function ToggleDarkMode({ className }: 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(localStorage.getItem("theme")); useEffect(() => {
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: any) => { 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);
updateSettings({ theme: theme as string }); localStorage.setItem("theme", newTheme);
}, [theme]); document.documentElement.setAttribute('data-theme', newTheme);
updateSettings({ theme: newTheme });
console.log("New theme set:", newTheme);
};
return ( const isDarkMode = theme.endsWith('-dark');
<div
className="tooltip 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={localStorage.getItem("theme") === "light" ? false : true}
/>
<i className="bi-sun-fill text-xl swap-on"></i>
<i className="bi-moon-fill text-xl swap-off"></i>
</label>
</div>
);
} }
-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}
/>
) : null}
</div>
);
};
export default UserListing;
+4 -15
View File
@@ -1,11 +1,11 @@
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";
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) {
@@ -19,7 +19,7 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
}; };
useEffect(() => { useEffect(() => {
updateSettings({ viewMode }); updateSettings({ viewMode: viewMode as ViewMode });
}, [viewMode]); }, [viewMode]);
return ( return (
@@ -35,17 +35,6 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
<i className="bi-grid w-4 h-4 text-neutral"></i> <i className="bi-grid w-4 h-4 text-neutral"></i>
</button> </button>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)}
className={`btn btn-square btn-sm btn-ghost ${
viewMode == ViewMode.Masonry
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
}`}
>
<i className="bi bi-columns-gap w-4 h-4 text-neutral"></i>
</button>
<button <button
onClick={(e) => onChangeViewMode(e, ViewMode.List)} onClick={(e) => onChangeViewMode(e, ViewMode.List)}
className={`btn btn-square btn-sm btn-ghost ${ className={`btn btn-square btn-sm btn-ghost ${
-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;
-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
-20
View File
@@ -1,20 +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 (e: any) {
if (e instanceof AxiosError) {
if (e.response?.status === 400) {
return
}
}
throw e
}
}
-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");
}
}
-2
View File
@@ -1,2 +0,0 @@
export { test } from "./fixtures";
export { expect } from "@playwright/test";
-19
View File
@@ -1,19 +0,0 @@
import { seedUser } from "@/e2e/data/user";
import { test as setup } from "../../index";
import { STORAGE_STATE } from "../../../playwright.config";
setup("Setup the default user", async ({ page, dashboardPage, loginPage }) => {
const username = process.env["TEST_USERNAME"] || "";
const password = process.env["TEST_PASSWORD"] || "";
await seedUser(username, password);
await loginPage.goto();
await loginPage.usernameInput.fill(username);
await loginPage.passwordInput.fill(password);
await loginPage.submitLoginButton.click();
await dashboardPage.container.waitFor({ state: "visible" });
await page.context().storageState({
path: STORAGE_STATE,
});
});
-8
View File
@@ -1,8 +0,0 @@
import { seedUser } from "@/e2e/data/user";
import { test as setup } from "../../index";
setup("Setup the default user", async () => {
const username = process.env["TEST_USERNAME"] || "";
const password = process.env["TEST_PASSWORD"] || "";
await seedUser(username, password);
});
-50
View File
@@ -1,50 +0,0 @@
import { expect, test } from "../../index";
test.describe(
"Login test suite",
{
tag: "@login",
},
async () => {
test("Logging in without credentials displays an error", async ({
loginPage,
}) => {
await loginPage.submitLoginButton.click();
const toast = await loginPage.getLatestToast();
await expect(toast.locator).toBeVisible();
await expect(toast.locator).toHaveAttribute("data-type", "error");
});
test("Logging in with an erroneous password displays an error", async ({
loginPage,
}) => {
await loginPage.usernameInput.fill(process.env["TEST_USERNAME"] || "");
await loginPage.passwordInput.fill("NOT_MY_PASSWORD_DNE_ERROR");
await loginPage.submitLoginButton.click();
const toast = await loginPage.getLatestToast();
await expect(toast.locator).toBeVisible();
await expect(toast.locator).toHaveAttribute("data-type", "error");
});
test("Logging in without valid credentials displays an error", async ({
loginPage,
}) => {
await loginPage.submitLoginButton.click();
const toast = await loginPage.getLatestToast();
await expect(toast.locator).toBeVisible();
await expect(toast.locator).toHaveAttribute("data-type", "error");
});
test("Logging in with a valid username and password works as expected", async ({
page,
loginPage,
dashboardPage,
}) => {
await loginPage.usernameInput.fill(process.env["TEST_USERNAME"] || "");
await loginPage.passwordInput.fill(process.env["TEST_PASSWORD"] || "");
await loginPage.submitLoginButton.click();
await expect(loginPage.loginForm).not.toBeVisible();
await expect(dashboardPage.container).toBeVisible();
});
}
);
-93
View File
@@ -1,93 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useSession } from "next-auth/react";
const useUsers = () => {
const { status } = useSession();
return useQuery({
queryKey: ["users"],
queryFn: async () => {
const response = await fetch("/api/v1/users");
if (!response.ok) {
if (response.status === 401) {
window.location.href = "/dashboard";
}
throw new Error("Failed to fetch users.");
}
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useAddUser = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (body: any) => {
if (body.password.length < 8) throw new Error(t("password_length_error"));
const load = toast.loading(t("creating_account"));
const response = await fetch("/api/v1/users", {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
toast.dismiss(load);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["users"], (oldData: any) => [...oldData, data]);
toast.success(t("user_created"));
},
onError: (error) => {
toast.error(error.message);
},
});
};
const useDeleteUser = () => {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (userId: number) => {
const load = toast.loading(t("deleting_user"));
const response = await fetch(`/api/v1/users/${userId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
toast.dismiss(load);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["users"], (oldData: any) =>
oldData.filter((user: any) => user.id !== variables)
);
toast.success(t("user_deleted"));
},
onError: (error) => {
toast.error(error.message);
},
});
};
export { useUsers, useAddUser, useDeleteUser };
-116
View File
@@ -1,116 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useSession } from "next-auth/react";
const useCollections = () => {
const { status } = useSession();
return useQuery({
queryKey: ["collections"],
queryFn: async (): Promise<CollectionIncludingMembersAndLinkCount[]> => {
const response = await fetch("/api/v1/collections");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useCreateCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: any) => {
const response = await fetch("/api/v1/collections", {
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
return queryClient.setQueryData(["collections"], (oldData: any) => {
return [...oldData, data];
});
},
});
};
const useUpdateCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: any) => {
const response = await fetch(`/api/v1/collections/${body.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
{
return queryClient.setQueryData(["collections"], (oldData: any) => {
return oldData.map((collection: any) =>
collection.id === data.id ? data : collection
);
});
}
},
// onMutate: async (data) => {
// await queryClient.cancelQueries({ queryKey: ["collections"] });
// queryClient.setQueryData(["collections"], (oldData: any) => {
// return oldData.map((collection: any) =>
// collection.id === data.id ? data : collection
// )
// });
// },
});
};
const useDeleteCollection = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/collections/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
return queryClient.setQueryData(["collections"], (oldData: any) => {
return oldData.filter((collection: any) => collection.id !== data.id);
});
},
});
};
export {
useCollections,
useCreateCollection,
useUpdateCollection,
useDeleteCollection,
};
-20
View File
@@ -1,20 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useQuery } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
const useDashboardData = () => {
const { status } = useSession();
return useQuery({
queryKey: ["dashboardData"],
queryFn: async (): Promise<LinkIncludingShortenedCollectionAndTags[]> => {
const response = await fetch("/api/v1/dashboard");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
export { useDashboardData };
-437
View File
@@ -1,437 +0,0 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
useQueryClient,
useMutation,
} from "@tanstack/react-query";
import { useMemo } from "react";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
LinkRequestQuery,
} from "@/types/global";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
const useLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
const queryParamsObject = {
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
collectionId:
params.collectionId ?? router.pathname === "/collections/[id]"
? router.query.id
: undefined,
tagId:
params.tagId ?? router.pathname === "/tags/[id]"
? router.query.id
: undefined,
pinnedOnly:
params.pinnedOnly ?? router.pathname === "/links/pinned"
? true
: undefined,
searchQueryString: params.searchQueryString,
searchByName: params.searchByName,
searchByUrl: params.searchByUrl,
searchByDescription: params.searchByDescription,
searchByTextContent: params.searchByTextContent,
searchByTags: params.searchByTags,
} as LinkRequestQuery;
const queryString = buildQueryString(queryParamsObject);
const { data, ...rest } = useFetchLinks(queryString);
const links = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return [...acc, ...page];
}, []);
}, [data]);
return {
links,
data: { ...data, ...rest },
} as {
links: LinkIncludingShortenedCollectionAndTags[];
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
};
};
const useFetchLinks = (params: string) => {
const { status } = useSession();
return useInfiniteQuery({
queryKey: ["links", { params }],
queryFn: async (params) => {
const response = await fetch(
"/api/v1/links?cursor=" +
params.pageParam +
((params.queryKey[1] as any).params
? "&" + (params.queryKey[1] as any).params
: "")
);
const data = await response.json();
return data.response;
},
initialPageParam: 0,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) {
return undefined;
}
return lastPage.at(-1).id;
},
enabled: status === "authenticated",
});
};
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
const useAddLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const response = await fetch("/api/v1/links", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(link),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)],
pageParams: oldData?.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useUpdateLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const response = await fetch(`/api/v1/links/${link.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(link),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) => (item.id === data.id ? data : item))
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useDeleteLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/links/${id}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => e.id !== data.id);
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.filter((item: any) => item.id !== data.id)
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useGetLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/links/${id}`);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) => (item.id === data.id ? data : item))
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkDeleteLinks = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (linkIds: number[]) => {
const response = await fetch("/api/v1/links", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ linkIds }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return linkIds;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.filter((e: any) => !data.includes(e.id));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.filter((item: any) => !data.includes(item.id))
),
pageParams: oldData.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useUploadFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ link, file }: any) => {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
} else {
return { ok: false, data: "Invalid file type." };
}
const response = await fetch("/api/v1/links", {
body: JSON.stringify({
...link,
type: linkType,
name: link.name ? link.name : file.name,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
}
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: [[data, ...oldData?.pages[0]], ...oldData?.pages.slice(1)],
pageParams: oldData?.pageParams,
};
});
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkEditLinks = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
links,
newData,
removePreviousTags,
}: {
links: LinkIncludingShortenedCollectionAndTags[];
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
"tags" | "collectionId"
>;
removePreviousTags: boolean;
}) => {
const response = await fetch("/api/v1/links", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ links, newData, removePreviousTags }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, { links, newData, removePreviousTags }) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData) return undefined;
return oldData.map((e: any) =>
data.find((d: any) => d.id === e.id) ? data : e
);
});
// TODO: Fix this
// queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
// if (!oldData) return undefined;
// return {
// pages: oldData.pages.map((page: any) => for (item of links) {
// page.map((item: any) => (item.id === data.id ? data : item))
// }
// ),
// pageParams: oldData.pageParams,
// };
// });
queryClient.invalidateQueries({ queryKey: ["links"] }); // Temporary workaround
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
export {
useLinks,
useAddLink,
useUpdateLink,
useDeleteLink,
useBulkDeleteLinks,
useUploadFile,
useGetLink,
useBulkEditLinks,
};
-93
View File
@@ -1,93 +0,0 @@
import {
InfiniteData,
useInfiniteQuery,
UseInfiniteQueryResult,
} from "@tanstack/react-query";
import { useMemo } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
LinkRequestQuery,
} from "@/types/global";
import { useRouter } from "next/router";
const usePublicLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
const queryParamsObject = {
sort: params.sort ?? Number(window.localStorage.getItem("sortBy")) ?? 0,
collectionId: params.collectionId ?? router.query.id,
tagId:
params.tagId ?? router.pathname === "/tags/[id]"
? router.query.id
: undefined,
pinnedOnly:
params.pinnedOnly ?? router.pathname === "/links/pinned"
? true
: undefined,
searchQueryString: params.searchQueryString,
searchByName: params.searchByName,
searchByUrl: params.searchByUrl,
searchByDescription: params.searchByDescription,
searchByTextContent: params.searchByTextContent,
searchByTags: params.searchByTags,
} as LinkRequestQuery;
const queryString = buildQueryString(queryParamsObject);
const { data, ...rest } = useFetchLinks(queryString);
const links = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return [...acc, ...page];
}, []);
}, [data]);
return {
links,
data: { ...data, ...rest },
} as {
links: LinkIncludingShortenedCollectionAndTags[];
data: UseInfiniteQueryResult<InfiniteData<any, unknown>, Error>;
};
};
const useFetchLinks = (params: string) => {
return useInfiniteQuery({
queryKey: ["links", { params }],
queryFn: async (params) => {
const response = await fetch(
"/api/v1/public/collections/links?cursor=" +
params.pageParam +
((params.queryKey[1] as any).params
? "&" + (params.queryKey[1] as any).params
: "")
);
const data = await response.json();
return data.response;
},
initialPageParam: 0,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => {
if (lastPage.length === 0) {
return undefined;
}
return lastPage.at(-1).id;
},
});
};
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
export { usePublicLinks };
-71
View File
@@ -1,71 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { TagIncludingLinkCount } from "@/types/global";
import { useSession } from "next-auth/react";
const useTags = () => {
const { status } = useSession();
return useQuery({
queryKey: ["tags"],
queryFn: async () => {
const response = await fetch("/api/v1/tags");
if (!response.ok) throw new Error("Failed to fetch tags.");
const data = await response.json();
return data.response;
},
enabled: status === "authenticated",
});
};
const useUpdateTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tag: TagIncludingLinkCount) => {
const response = await fetch(`/api/v1/tags/${tag.id}`, {
body: JSON.stringify(tag),
headers: {
"Content-Type": "application/json",
},
method: "PUT",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["tags"], (oldData: any) =>
oldData.map((tag: TagIncludingLinkCount) =>
tag.id === data.id ? data : tag
)
);
},
});
};
const useRemoveTag = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tagId: number) => {
const response = await fetch(`/api/v1/tags/${tagId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["tags"], (oldData: any) =>
oldData.filter((tag: TagIncludingLinkCount) => tag.id !== variables)
);
},
});
};
export { useTags, useUpdateTag, useRemoveTag };
-68
View File
@@ -1,68 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AccessToken } from "@prisma/client";
import { useSession } from "next-auth/react";
const useTokens = () => {
const { status } = useSession();
return useQuery({
queryKey: ["tokens"],
queryFn: async () => {
const response = await fetch("/api/v1/tokens");
if (!response.ok) throw new Error("Failed to fetch tokens.");
const data = await response.json();
return data.response as AccessToken[];
},
enabled: status === "authenticated",
});
};
const useAddToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (body: Partial<AccessToken>) => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data) => {
queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) => [
...oldData,
data.token,
]);
},
});
};
const useRevokeToken = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (tokenId: number) => {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data.response;
},
onSuccess: (data, variables) => {
queryClient.setQueryData(["tokens"], (oldData: AccessToken[]) =>
oldData.filter((token: Partial<AccessToken>) => token.id !== variables)
);
},
});
};
export { useTokens, useAddToken, useRevokeToken };
-53
View File
@@ -1,53 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
const useUser = () => {
const { data, status } = useSession();
const userId = data?.user.id;
return useQuery({
queryKey: ["user"],
queryFn: async () => {
const response = await fetch(`/api/v1/users/${userId}`);
if (!response.ok) throw new Error("Failed to fetch user data.");
const data = await response.json();
return data.response;
},
enabled: !!userId && status === "authenticated",
placeholderData: {},
});
};
const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (user: any) => {
const response = await fetch(`/api/v1/users/${user.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
const data = await response.json();
if (!response.ok) throw new Error(data.response);
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(["user"], data.response);
},
onMutate: async (user) => {
await queryClient.cancelQueries({ queryKey: ["user"] });
queryClient.setQueryData(["user"], (oldData: any) => {
return { ...oldData, ...user };
});
},
});
};
export { useUser, useUpdateUser };
-34
View File
@@ -1,34 +0,0 @@
import { Member } from "@/types/global";
import { useEffect, useState } from "react";
import { useCollections } from "./store/collections";
import { useUser } from "./store/user";
export default function useCollectivePermissions(collectionIds: number[]) {
const { data: collections = [] } = useCollections();
const { data: user = {} } = useUser();
const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => {
for (const collectionId of collectionIds) {
const collection = collections.find((e) => e.id === collectionId);
if (collection) {
let getPermission: Member | undefined = collection.members.find(
(e) => e.userId === user.id
);
if (
getPermission?.canCreate === false &&
getPermission?.canUpdate === false &&
getPermission?.canDelete === false
)
getPermission = undefined;
setPermissions(user.id === collection.ownerId || getPermission);
}
}
}, [user, collections, collectionIds]);
return permissions;
}
+19 -1
View File
@@ -1,14 +1,32 @@
import useCollectionStore from "@/store/collections";
import { useEffect } from "react"; import { useEffect } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useTagStore from "@/store/tags";
import useAccountStore from "@/store/account";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
export default function useInitialData() { export default function useInitialData() {
const { status, data } = useSession(); const { status, data } = useSession();
const { setCollections } = useCollectionStore();
const { setTags } = useTagStore();
// const { setLinks } = useLinkStore();
const { account, setAccount } = useAccountStore();
const { setSettings } = useLocalSettingsStore(); const { setSettings } = useLocalSettingsStore();
useEffect(() => { useEffect(() => {
setSettings(); setSettings();
if (status === "authenticated") {
// Get account info
setAccount(data?.user.id as number);
}
}, [status, data]); }, [status, data]);
return status; // Get the rest of the data
useEffect(() => {
if (account.id && (!process.env.NEXT_PUBLIC_STRIPE || account.username)) {
setCollections();
setTags();
// setLinks();
}
}, [account]);
} }
+90
View File
@@ -0,0 +1,90 @@
import { LinkRequestQuery } from "@/types/global";
import { useEffect } from "react";
import useDetectPageBottom from "./useDetectPageBottom";
import { useRouter } from "next/router";
import useLinkStore from "@/store/links";
export default function useLinks(
{
sort,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
}: LinkRequestQuery = { sort: 0 }
) {
const { links, setLinks, resetLinks } = useLinkStore();
const router = useRouter();
const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => {
const params = {
sort,
cursor,
collectionId,
tagId,
pinnedOnly,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTags,
searchByTextContent,
};
const buildQueryString = (params: LinkRequestQuery) => {
return Object.keys(params)
.filter((key) => params[key as keyof LinkRequestQuery] !== undefined)
.map(
(key) =>
`${encodeURIComponent(key)}=${encodeURIComponent(
params[key as keyof LinkRequestQuery] as string
)}`
)
.join("&");
};
let queryString = buildQueryString(params);
let basePath;
if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard";
else if (router.pathname.startsWith("/public/collections/[id]")) {
queryString = queryString + "&collectionId=" + router.query.id;
basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links";
const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json();
if (response.ok) setLinks(data.response, isInitialCall);
};
useEffect(() => {
resetLinks();
getLinks(true);
}, [
router,
sort,
searchQueryString,
searchByName,
searchByUrl,
searchByDescription,
searchByTextContent,
searchByTags,
]);
useEffect(() => {
if (reachedBottom) getLinks(false, links?.at(-1)?.id);
setReachedBottom(false);
}, [reachedBottom]);
}
+7 -7
View File
@@ -1,12 +1,12 @@
import useAccountStore from "@/store/account";
import useCollectionStore from "@/store/collections";
import { Member } from "@/types/global"; import { Member } from "@/types/global";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useCollections } from "./store/collections";
import { useUser } from "./store/user";
export default function usePermissions(collectionId: number) { export default function usePermissions(collectionId: number) {
const { data: collections = [] } = useCollections(); const { collections } = useCollectionStore();
const { data: user = {} } = useUser(); const { account } = useAccountStore();
const [permissions, setPermissions] = useState<Member | true>(); const [permissions, setPermissions] = useState<Member | true>();
useEffect(() => { useEffect(() => {
@@ -14,7 +14,7 @@ export default function usePermissions(collectionId: number) {
if (collection) { if (collection) {
let getPermission: Member | undefined = collection.members.find( let getPermission: Member | undefined = collection.members.find(
(e) => e.userId === user.id (e) => e.userId === account.id
); );
if ( if (
@@ -24,9 +24,9 @@ export default function usePermissions(collectionId: number) {
) )
getPermission = undefined; getPermission = undefined;
setPermissions(user.id === collection.ownerId || getPermission); setPermissions(account.id === collection.ownerId || getPermission);
} }
}, [user, collections, collectionId]); }, [account, collections, collectionId]);
return permissions; return permissions;
} }
+66 -59
View File
@@ -1,77 +1,84 @@
import { ReactNode, useEffect, useState } from "react"; import { ReactNode } from "react";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Loader from "../components/Loader";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useInitialData from "@/hooks/useInitialData"; import useInitialData from "@/hooks/useInitialData";
import { useUser } from "@/hooks/store/user"; import useAccountStore from "@/store/account";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
} }
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
export default function AuthRedirect({ children }: Props) { export default function AuthRedirect({ children }: Props) {
const router = useRouter(); const router = useRouter();
const { status } = useSession(); const { status, data } = useSession();
const [shouldRenderChildren, setShouldRenderChildren] = useState(false); const [redirect, setRedirect] = useState(true);
const { data: user = {} } = useUser(); const { account } = useAccountStore();
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
useInitialData(); useInitialData();
useEffect(() => { useEffect(() => {
const isLoggedIn = status === "authenticated"; if (!router.pathname.startsWith("/public")) {
const isUnauthenticated = status === "unauthenticated"; if (
const isPublicPage = router.pathname.startsWith("/public"); status === "authenticated" &&
const hasInactiveSubscription = account.id &&
user.id && !user.subscription?.active && stripeEnabled; !account.subscription?.active &&
stripeEnabled
// There are better ways of doing this... but this one works for now
const routes = [
{ path: "/login", isProtected: false },
{ path: "/register", isProtected: false },
{ path: "/confirmation", isProtected: false },
{ path: "/forgot", isProtected: false },
{ path: "/auth/reset-password", isProtected: false },
{ path: "/", isProtected: false },
{ path: "/subscribe", isProtected: true },
{ path: "/dashboard", isProtected: true },
{ path: "/settings", isProtected: true },
{ path: "/collections", isProtected: true },
{ path: "/links", isProtected: true },
{ path: "/tags", isProtected: true },
{ path: "/preserved", isProtected: true },
{ path: "/admin", isProtected: true },
{ path: "/search", isProtected: true },
];
if (isPublicPage) {
setShouldRenderChildren(true);
} else {
if (isLoggedIn && hasInactiveSubscription) {
redirectTo("/subscribe");
} else if (
isLoggedIn &&
!routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)
) { ) {
redirectTo("/dashboard"); router.push("/subscribe").then(() => {
} else if ( setRedirect(false);
isUnauthenticated && });
routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)
) {
redirectTo("/login");
} else {
setShouldRenderChildren(true);
} }
// Redirect to "/choose-username" if user is authenticated and is either a subscriber OR subscription is undefiend, and doesn't have a username
else if (
emailEnabled &&
status === "authenticated" &&
account.subscription?.active &&
stripeEnabled &&
account.id &&
!account.username
) {
router.push("/choose-username").then(() => {
setRedirect(false);
});
} else if (
status === "authenticated" &&
account.id &&
(router.pathname === "/login" ||
router.pathname === "/register" ||
router.pathname === "/confirmation" ||
router.pathname === "/subscribe" ||
router.pathname === "/choose-username" ||
router.pathname === "/forgot" ||
router.pathname === "/")
) {
router.push("/dashboard").then(() => {
setRedirect(false);
});
} else if (
status === "unauthenticated" &&
!(
router.pathname === "/login" ||
router.pathname === "/register" ||
router.pathname === "/confirmation" ||
router.pathname === "/forgot"
)
) {
router.push("/login").then(() => {
setRedirect(false);
});
} else if (status === "loading") setRedirect(true);
else setRedirect(false);
} else {
setRedirect(false);
} }
}, [status, user, router.pathname]); }, [status, account, router.pathname]);
function redirectTo(destination: string) { if (status !== "loading" && !redirect) return <>{children}</>;
router.push(destination).then(() => setShouldRenderChildren(true)); else return <></>;
} // return <>{children}</>;
if (status !== "loading" && shouldRenderChildren) {
return <>{children}</>;
} else {
return <></>;
}
} }
+8 -19
View File
@@ -1,27 +1,18 @@
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import React, { ReactNode } from "react"; import React, { ReactNode, useEffect } from "react";
import { Trans } from "next-i18next";
interface Props { interface Props {
text?: string; text?: string;
children: ReactNode; children: ReactNode;
"data-testid"?: string;
} }
export default function CenteredForm({ export default function CenteredForm({ text, children }: Props) {
text,
children,
"data-testid": dataTestId,
}: Props) {
const { settings } = useLocalSettingsStore(); const { settings } = useLocalSettingsStore();
return ( return (
<div <div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5">
className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5"
data-testid={dataTestId}
>
<div className="m-auto flex flex-col gap-2 w-full"> <div className="m-auto flex flex-col gap-2 w-full">
{settings.theme ? ( {settings.theme ? (
<Image <Image
@@ -41,13 +32,11 @@ export default function CenteredForm({
) : undefined} ) : undefined}
{children} {children}
<p className="text-center text-xs text-neutral mb-5"> <p className="text-center text-xs text-neutral mb-5">
<Trans © {new Date().getFullYear()}{" "}
values={{ date: new Date().getFullYear() }} <Link href="https://linkwarden.app" className="font-semibold">
i18nKey="all_rights_reserved" Linkwarden
components={[ </Link>
<Link href="https://linkwarden.app" className="font-semibold" />, . All rights reserved.
]}
/>
</p> </p>
</div> </div>
</div> </div>
+19 -12
View File
@@ -1,5 +1,5 @@
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import Announcement from "@/components/Announcement"; import AnnouncementBar from "@/components/AnnouncementBar";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import getLatestVersion from "@/lib/client/getLatestVersion"; import getLatestVersion from "@/lib/client/getLatestVersion";
@@ -33,20 +33,27 @@ export default function MainLayout({ children }: Props) {
}; };
return ( return (
<div className="flex" data-testid="dashboard-wrapper"> <>
{showAnnouncement ? ( {showAnnouncement ? (
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} /> <AnnouncementBar toggleAnnouncementBar={toggleAnnouncementBar} />
) : undefined} ) : undefined}
<div className="hidden lg:block">
<Sidebar className={`fixed top-0`} />
</div>
<div <div className="flex">
className={`w-full sm:pb-0 pb-20 flex flex-col min-h-screen lg:ml-80`} <div className="hidden lg:block">
> <Sidebar
<Navbar /> className={`fixed ${showAnnouncement ? "top-10" : "top-0"}`}
{children} />
</div>
<div
className={`w-full flex flex-col min-h-${
showAnnouncement ? "full" : "screen"
} lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
>
<Navbar />
{children}
</div>
</div> </div>
</div> </>
); );
} }

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