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
89 changed files with 952 additions and 2288 deletions
+1 -1
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
-1
View File
@@ -47,4 +47,3 @@ prisma/dev.db
# docker # docker
pgdata pgdata
certificates
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./ COPY ./package.json ./yarn.lock ./playwright.config.ts ./
# Increase timeout to pass github actions arm64 build # Increase timeout to pass github actions arm64 build
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000 RUN yarn install --network-timeout 10000000
RUN npx playwright install-deps && \ RUN npx playwright install-deps && \
apt-get clean && \ apt-get clean && \
+1 -3
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.
+1 -3
View File
@@ -9,7 +9,6 @@ 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";
type Props = { type Props = {
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
@@ -71,7 +70,6 @@ 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>
@@ -172,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}
-160
View File
@@ -1,160 +0,0 @@
import useCollectionStore from "@/store/collections";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
type Props = {
links: boolean;
};
const CollectionSelection = ({ links }: Props) => {
const { collections } = useCollectionStore();
const [active, setActive] = useState("");
const router = useRouter();
useEffect(() => {
setActive(router.asPath);
}, [router, collections]);
return (
<div>
{collections[0] ? (
collections
.sort((a, b) => a.name.localeCompare(b.name))
.filter((e) => e.parentId === null)
.map((e, i) => (
<CollectionItem
key={i}
collection={e}
active={active}
collections={collections}
/>
))
) : (
<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>
)}
</div>
);
};
export default CollectionSelection;
const CollectionItem = ({
collection,
active,
collections,
}: {
collection: CollectionIncludingMembersAndLinkCount;
active: string;
collections: CollectionIncludingMembersAndLinkCount[];
}) => {
const hasChildren = collections.some((e) => e.parentId === collection.id);
const router = useRouter();
// Check if the current collection or any of its subcollections is active
const isActiveOrParentOfActive = React.useMemo(() => {
const isActive = active === `/collections/${collection.id}`;
if (isActive) return true;
const checkIfParentOfActive = (parentId: number): boolean => {
return collections.some((e) => {
if (e.id === parseInt(active.split("/collections/")[1])) {
if (e.parentId === parentId) return true;
if (e.parentId) return checkIfParentOfActive(e.parentId);
}
return false;
});
};
return checkIfParentOfActive(collection.id as number);
}, [active, collection.id, collections]);
const [isOpen, setIsOpen] = useState(isActiveOrParentOfActive);
useEffect(() => {
setIsOpen(isActiveOrParentOfActive);
}, [isActiveOrParentOfActive]);
return hasChildren ? (
<details open={isOpen}>
<summary
className={`${
active === `/collections/${collection.id}`
? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 rounded-md flex w-full items-center cursor-pointer mb-1 px-2`}
>
<Link href={`/collections/${collection.id}`} className="w-full">
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full 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>
</summary>
{hasChildren && (
<div className="ml-3 pl-1 border-l border-neutral-content">
{collections
.filter((e) => e.parentId === collection.id)
.map((subCollection) => (
<CollectionItem
key={subCollection.id}
collection={subCollection}
active={active}
collections={collections}
/>
))}
</div>
)}
</details>
) : (
<Link href={`/collections/${collection.id}`} className="w-full">
<div
className={`${
active === `/collections/${collection.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 mb-1`}
>
<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>
);
};
-2
View File
@@ -1,4 +1,3 @@
import { dropdownTriggerer } from "@/lib/client/utils";
import React from "react"; import React from "react";
type Props = { type Props = {
@@ -21,7 +20,6 @@ export default function FilterSearchDropdown({
<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>
+1 -8
View File
@@ -9,14 +9,7 @@ export default function CardView({
return ( return (
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <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) => { {links.map((e, i) => {
return ( return <LinkCard key={i} link={e} count={i} />;
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
/>
);
})} })}
</div> </div>
); );
+1 -8
View File
@@ -9,14 +9,7 @@ export default function ListView({
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{links.map((e, i) => { {links.map((e, i) => {
return ( return <LinkList key={i} link={e} count={i} />;
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
/>
);
})} })}
</div> </div>
); );
+8 -10
View File
@@ -21,15 +21,9 @@ type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
className?: string; className?: string;
flipDropdown?: boolean;
}; };
export default function LinkGrid({ export default function LinkGrid({ link, count, className }: Props) {
link,
count,
className,
flipDropdown,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { links, getLink } = useLinkStore(); const { links, getLink } = useLinkStore();
@@ -132,12 +126,17 @@ export default function LinkGrid({
{unescapeString(link.name || link.description) || link.url} {unescapeString(link.name || link.description) || link.url}
</p> </p>
<div title={link.url || ""} className="w-fit"> <Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
className="w-fit"
>
<div className="flex gap-1 item-center select-none text-neutral mt-1"> <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> <i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p> <p className="text-sm truncate">{shortendURL}</p>
</div> </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]" />
@@ -205,7 +204,6 @@ export default function LinkGrid({
position="top-[10.75rem] right-3" position="top-[10.75rem] right-3"
toggleShowInfo={() => setShowInfo(!showInfo)} toggleShowInfo={() => setShowInfo(!showInfo)}
linkInfo={showInfo} linkInfo={showInfo}
flipDropdown={flipDropdown}
/> />
</div> </div>
); );
@@ -10,7 +10,6 @@ import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsMod
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@@ -18,7 +17,6 @@ type Props = {
position?: string; position?: string;
toggleShowInfo?: () => void; toggleShowInfo?: () => void;
linkInfo?: boolean; linkInfo?: boolean;
flipDropdown?: boolean;
}; };
export default function LinkActions({ export default function LinkActions({
@@ -26,7 +24,6 @@ export default function LinkActions({
toggleShowInfo, toggleShowInfo,
position, position,
linkInfo, linkInfo,
flipDropdown,
}: Props) { }: Props) {
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
@@ -67,19 +64,18 @@ export default function LinkActions({
return ( return (
<> <>
<div <div
className={`dropdown dropdown-left dropdown-end absolute ${ className={`dropdown dropdown-left absolute ${
position || "top-3 right-3" position || "top-3 right-3"
} 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 className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1 translate-y-10"> <ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
{permissions === true ? ( {permissions === true ? (
<li> <li>
<div <div
+13 -17
View File
@@ -11,21 +11,14 @@ import LinkDate from "@/components/LinkViews/LinkComponents/LinkDate";
import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection"; import LinkCollection from "@/components/LinkViews/LinkComponents/LinkCollection";
import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon"; import LinkIcon from "@/components/LinkViews/LinkComponents/LinkIcon";
import Link from "next/link"; import Link from "next/link";
import { isPWA } from "@/lib/client/utils";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
count: number; count: number;
className?: string; className?: string;
flipDropdown?: boolean;
}; };
export default function LinkCardCompact({ export default function LinkCardCompact({ link, count, className }: Props) {
link,
count,
className,
flipDropdown,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { links } = useLinkStore(); const { links } = useLinkStore();
@@ -59,16 +52,16 @@ export default function LinkCardCompact({
<> <>
<div <div
className={`border-neutral-content relative ${ className={`border-neutral-content relative ${
!showInfo && !isPWA() ? "hover:bg-base-300 p-3" : "py-3" !showInfo ? "hover:bg-base-300" : ""
} duration-200 rounded-lg`} } duration-200 rounded-lg`}
> >
<Link <Link
href={link.url || ""} href={link.url || ""}
target="_blank" target="_blank"
className="flex items-start cursor-pointer" className="flex items-center cursor-pointer py-3 px-3"
> >
<div className="shrink-0"> <div className="shrink-0">
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" /> <LinkIcon link={link} width="sm:w-12 w-8" />
</div> </div>
<div className="w-[calc(100%-56px)] ml-2"> <div className="w-[calc(100%-56px)] ml-2">
@@ -77,13 +70,16 @@ export default function LinkCardCompact({
</p> </p>
<div className="mt-1 flex flex-col sm:flex-row sm:items-center gap-2 text-xs text-neutral"> <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 w-fit text-neutral flex-wrap"> <div className="flex items-center gap-2">
{collection ? ( {collection ? (
<LinkCollection link={link} collection={collection} /> <>
<LinkCollection link={link} collection={collection} />
&middot;
</>
) : undefined} ) : undefined}
{link.url ? ( {link.url ? (
<div className="flex items-center gap-1 w-fit text-neutral truncate"> <div className="flex items-center gap-1 max-w-full w-fit text-neutral">
<i className="bi-link-45deg text-lg" /> <i className="bi-link-45deg text-base" />
<p className="truncate w-full">{shortendURL}</p> <p className="truncate w-full">{shortendURL}</p>
</div> </div>
) : ( ) : (
@@ -91,8 +87,9 @@ export default function LinkCardCompact({
{link.type} {link.type}
</div> </div>
)} )}
<LinkDate link={link} />
</div> </div>
<span className="hidden sm:block">&middot;</span>
<LinkDate link={link} />
</div> </div>
</div> </div>
</Link> </Link>
@@ -101,7 +98,6 @@ export default function LinkCardCompact({
link={link} link={link}
collection={collection} collection={collection}
position="top-3 right-3" position="top-3 right-3"
flipDropdown={flipDropdown}
// toggleShowInfo={() => setShowInfo(!showInfo)} // toggleShowInfo={() => setShowInfo(!showInfo)}
// linkInfo={showInfo} // linkInfo={showInfo}
/> />
-96
View File
@@ -1,96 +0,0 @@
import { dropdownTriggerer, isIphone } 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";
type Props = {};
export default function MobileNavigation({}: Props) {
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() ? "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"
>
New Link
</div>
</li>
{/* <li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
Upload File
</div>
</li> */}
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
>
New Collection
</div>
</li>
</ul>
</div>
<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>
);
}
+23 -52
View File
@@ -1,6 +1,5 @@
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;
@@ -9,59 +8,31 @@ type Props = {
}; };
export default function Modal({ toggleModal, className, children }: Props) { export default function Modal({ toggleModal, className, children }: 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={() => setTimeout(() => toggleModal(), 100)} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
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" /> <div
<ClickAwayHandler onClickOutside={() => setDrawerIsOpen(false)}> onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
<Drawer.Content className="flex flex-col rounded-t-2xl h-[90%] mt-24 fixed bottom-0 left-0 right-0 z-30"> className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
<div className="p-4 pb-32 bg-base-100 rounded-t-2xl flex-1 border-neutral-content border-t overflow-y-auto"> >
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-neutral mb-5" /> <i className="bi-x text-neutral text-2xl"></i>
{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">
<ClickAwayHandler
onClickOutside={toggleModal}
className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
>
<div className="slide-up mt-auto sm:m-auto relative border-neutral-content rounded-t-2xl sm:rounded-2xl border-t sm:border shadow-2xl p-5 bg-base-100 overflow-y-auto sm:overflow-y-visible">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-4 right-3 btn btn-sm outline-none btn-circle btn-ghost z-10"
>
<i className="bi-x text-neutral text-2xl"></i>
</div>
{children}
</div> </div>
</ClickAwayHandler> {children}
</div> </div>
); </ClickAwayHandler>
} </div>
);
} }
@@ -110,7 +110,7 @@ export default function EditCollectionModal({
className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto" className="btn btn-accent dark:border-violet-400 text-white w-fit ml-auto"
onClick={submit} onClick={submit}
> >
Save Changes Save
</button> </button>
</div> </div>
</Modal> </Modal>
@@ -9,7 +9,6 @@ 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";
type Props = { type Props = {
onClose: Function; onClose: Function;
@@ -265,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}
@@ -445,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}
> >
Save Changes Save
</button> </button>
)} )}
</div> </div>
+1 -1
View File
@@ -157,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}
> >
Save Changes Save
</button> </button>
</div> </div>
</Modal> </Modal>
+3 -13
View File
@@ -5,20 +5,17 @@ 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";
type Props = { type Props = {
onClose: Function; onClose: Function;
parent?: CollectionIncludingMembersAndLinkCount;
}; };
export default function NewCollectionModal({ onClose, parent }: Props) { export default function NewCollectionModal({ onClose }: Props) {
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);
@@ -50,14 +47,7 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
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">New Sub-Collection</p>
<p className="capitalize text-sm">For {parent.name}</p>
</>
) : (
<p className="text-xl font-thin">Create a New Collection</p>
)}
<div className="divider mb-3 mt-1"></div> <div className="divider mb-3 mt-1"></div>
+1 -1
View File
@@ -179,7 +179,7 @@ export default function NewLinkModal({ onClose }: Props) {
setLink({ ...link, description: e.target.value }) setLink({ ...link, description: e.target.value })
} }
placeholder="Will be auto generated if nothing is provided." 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>
-227
View File
@@ -1,227 +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 useTokenStore from "@/store/tokens";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = {
onClose: Function;
};
export default function NewTokenModal({ onClose }: Props) {
const [newToken, setNewToken] = useState("");
const { addToken } = useTokenStore();
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("Creating...");
const { ok, data } = await addToken(token);
toast.dismiss(load);
if (ok) {
toast.success(`Created!`);
setNewToken((data as any).secretKey);
} else toast.error(data as string);
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
{newToken ? (
<div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">Access Token Created</p>
<p>
Your new token has been created. Please copy it and store it
somewhere safe. You will not be able to see it again.
</p>
<TextInput
spellCheck={false}
value={newToken}
onChange={() => {}}
className="w-full"
/>
<button
onClick={() => {
navigator.clipboard.writeText(newToken);
toast.success("Copied to clipboard!");
}}
className="btn btn-primary w-fit mx-auto"
>
Copy to Clipboard
</button>
</div>
) : (
<>
<p className="text-xl font-thin">Create an 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">Name</p>
<TextInput
value={token.name}
onChange={(e) => setToken({ ...token, name: e.target.value })}
placeholder="e.g. For the iOS shortcut"
className="bg-base-200"
/>
</div>
<div className="w-full sm:w-fit">
<p className="mb-2">Expires in</p>
<div className="dropdown dropdown-bottom dropdown-end w-full">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-outline w-full sm:w-36 flex items-center btn-sm h-10"
>
{token.expires === TokenExpiry.sevenDays && "7 Days"}
{token.expires === TokenExpiry.oneMonth && "30 Days"}
{token.expires === TokenExpiry.twoMonths && "60 Days"}
{token.expires === TokenExpiry.threeMonths && "90 Days"}
{token.expires === TokenExpiry.never && "No Expiration"}
</div>
<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">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">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">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">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">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}
>
Create Access Token
</button>
</div>
</>
)}
</Modal>
);
}
@@ -69,13 +69,11 @@ export default function PreservedFormatsModal({ onClose, activeLink }: 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.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable && link.readable &&
link.readable !== "pending" link.readable !== "pending"
); );
@@ -217,7 +215,10 @@ export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
<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>Refresh Preserved Formats</p> <p>Refresh Preserved Formats</p>
<p className="text-xs"> <p className="text-xs">
@@ -1,62 +0,0 @@
import React, { useEffect, useState } from "react";
import useLinkStore from "@/store/links";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
type Props = {
onClose: Function;
activeToken: AccessToken;
};
export default function DeleteTokenModal({ onClose, activeToken }: Props) {
const [token, setToken] = useState<AccessToken>(activeToken);
const { revokeToken } = useTokenStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
useEffect(() => {
setToken(activeToken);
}, []);
const deleteLink = async () => {
console.log(token);
const load = toast.loading("Deleting...");
const response = await revokeToken(token.id as number);
toast.dismiss(load);
response.ok && toast.success(`Token Revoked.`);
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Revoke Token</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>
Are you sure you want to revoke this Access Token? Any apps or
services using this token will no longer be able to access Linkwarden
using it.
</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<i className="bi-trash text-xl" />
Revoke
</button>
</div>
</Modal>
);
}
+26 -38
View File
@@ -13,8 +13,6 @@ import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal";
import Link from "next/link"; 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";
export default function Navbar() { export default function Navbar() {
const { settings, updateSettings } = useLocalSettingsStore(); const { settings, updateSettings } = useLocalSettingsStore();
@@ -28,21 +26,23 @@ export default function Navbar() {
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const handleToggle = () => { const handleToggle = () => {
if (settings.theme === "dark") { const [colorTheme, mode] = (settings.theme || "default-light").split('-');
updateSettings({ theme: "light" }); const newMode = mode === "dark" ? "light" : "dark";
} else { const newTheme = `${colorTheme}-${newMode}`;
updateSettings({ theme: "dark" }); 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);
@@ -52,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 hidden sm:inline-flex"
> >
<i className="bi-list text-2xl leading-none"></i> <i className="bi-list text-2xl leading-none"></i>
</div> </div>
@@ -64,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="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>
@@ -121,12 +117,7 @@ export default function Navbar() {
</div> </div>
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
<div <div tabIndex={0} role="button" className="btn btn-circle btn-ghost">
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="btn btn-circle btn-ghost"
>
<ProfilePhoto <ProfilePhoto
src={account.image ? account.image : undefined} src={account.image ? account.image : undefined}
priority={true} priority={true}
@@ -143,17 +134,17 @@ export default function Navbar() {
Settings Settings
</Link> </Link>
</li> </li>
<li className="block sm:hidden"> <li>
<div <div
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
handleToggle(); handleToggle();
}} }}
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
Switch to {settings.theme === "light" ? "Dark" : "Light"} Switch to {(settings.theme || "default-light").endsWith("-dark") ? "Light" : "Dark"}
</div> </div>
</li> </li>
<li> <li>
<div <div
@@ -170,9 +161,6 @@ export default function Navbar() {
</ul> </ul>
</div> </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}>
+1 -1
View File
@@ -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
+4 -4
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
export default function SettingsSidebar({ className }: { className?: string }) { export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.4.8"; const LINKWARDEN_VERSION = "v2.4.7";
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
@@ -64,16 +64,16 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div> </div>
</Link> </Link>
<Link href="/settings/access-tokens"> <Link href="/settings/api">
<div <div
className={`${ className={`${
active === `/settings/access-tokens` 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">Access Tokens</p> <p className="truncate w-full pr-7">API Keys</p>
</div> </div>
</Link> </Link>
+43 -4
View File
@@ -5,7 +5,6 @@ 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 CollectionSelection from "@/components/CollectionSelection";
export default function Sidebar({ className }: { className?: string }) { export default function Sidebar({ className }: { className?: string }) {
const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => { const [tagDisclosure, setTagDisclosure] = useState<boolean>(() => {
@@ -45,7 +44,7 @@ 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 || ""
}`} }`}
> >
@@ -98,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">
<CollectionSelection links={true} /> {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>
-2
View File
@@ -1,6 +1,5 @@
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction } from "react";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import { dropdownTriggerer } from "@/lib/client/utils";
type Props = { type Props = {
sortBy: Sort; sortBy: Sort;
@@ -13,7 +12,6 @@ export default function SortDropdown({ sortBy, setSort }: Props) {
<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-chevron-expand text-neutral text-2xl"></i> <i className="bi-chevron-expand text-neutral text-2xl"></i>
-3
View File
@@ -8,7 +8,6 @@ type Props = {
onChange: ChangeEventHandler<HTMLInputElement>; onChange: ChangeEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined; onKeyDown?: KeyboardEventHandler<HTMLInputElement> | undefined;
className?: string; className?: string;
spellCheck?: boolean;
}; };
export default function TextInput({ export default function TextInput({
@@ -19,11 +18,9 @@ export default function TextInput({
onChange, onChange,
onKeyDown, onKeyDown,
className, className,
spellCheck,
}: Props) { }: Props) {
return ( return (
<input <input
spellCheck={spellCheck}
autoFocus={autoFocus} autoFocus={autoFocus}
type={type ? type : "text"} type={type ? type : "text"}
placeholder={placeholder} placeholder={placeholder}
+35 -28
View File
@@ -2,39 +2,46 @@ import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
type Props = { type Props = {
className?: string; className?: string;
}; };
export default function ToggleDarkMode({ className }: Props) { export default function ToggleDarkMode({ className }: Props) {
const { settings, updateSettings } = useLocalSettingsStore(); const { 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={`Switch to ${settings.theme === "light" ? "Dark" : "Light"}`} <div className="tooltip tooltip-bottom" data-tip={`Switch to ${isDarkMode ? "Light" : "Dark"}`}>
> <label className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}>
<label <input type="checkbox" onChange={handleToggle} className="theme-controller" checked={isDarkMode} />
className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`} <i className="bi-sun-fill text-xl swap-on"></i>
> <i className="bi-moon-fill text-xl swap-off"></i>
<input </label>
type="checkbox" </div>
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>
);
} }
+1 -1
View File
@@ -46,7 +46,7 @@ export default function MainLayout({ children }: Props) {
</div> </div>
<div <div
className={`w-full sm:pb-0 pb-20 flex flex-col min-h-${ className={`w-full flex flex-col min-h-${
showAnnouncement ? "full" : "screen" showAnnouncement ? "full" : "screen"
} lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`} } lg:ml-80 ${showAnnouncement ? "mt-10" : ""}`}
> >
+1 -2
View File
@@ -43,8 +43,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
? await validateUrlSize(link.url) ? await validateUrlSize(link.url)
: undefined; : undefined;
if (validatedUrl === null) if (validatedUrl === null) throw "File is too large to be stored.";
throw "Something went wrong while retrieving the file size.";
const contentType = validatedUrl?.get("content-type"); const contentType = validatedUrl?.get("content-type");
let linkType = "url"; let linkType = "url";
@@ -37,8 +37,6 @@ export default async function deleteCollection(
} }
const deletedCollection = await prisma.$transaction(async () => { const deletedCollection = await prisma.$transaction(async () => {
await deleteSubCollections(collectionId);
await prisma.usersAndCollections.deleteMany({ await prisma.usersAndCollections.deleteMany({
where: { where: {
collection: { collection: {
@@ -55,7 +53,7 @@ export default async function deleteCollection(
}, },
}); });
await removeFolder({ filePath: `archives/${collectionId}` }); removeFolder({ filePath: `archives/${collectionId}` });
return await prisma.collection.delete({ return await prisma.collection.delete({
where: { where: {
@@ -66,35 +64,3 @@ export default async function deleteCollection(
return { response: deletedCollection, status: 200 }; return { response: deletedCollection, status: 200 };
} }
async function deleteSubCollections(collectionId: number) {
const subCollections = await prisma.collection.findMany({
where: { parentId: collectionId },
});
for (const subCollection of subCollections) {
await deleteSubCollections(subCollection.id);
await prisma.usersAndCollections.deleteMany({
where: {
collection: {
id: subCollection.id,
},
},
});
await prisma.link.deleteMany({
where: {
collection: {
id: subCollection.id,
},
},
});
await prisma.collection.delete({
where: { id: subCollection.id },
});
await removeFolder({ filePath: `archives/${subCollection.id}` });
}
}
@@ -1,34 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getCollectionById(
userId: number,
collectionId: number
) {
const collections = await prisma.collection.findFirst({
where: {
id: collectionId,
OR: [
{ ownerId: userId },
{ members: { some: { user: { id: userId } } } },
],
},
include: {
_count: {
select: { links: true },
},
members: {
include: {
user: {
select: {
username: true,
name: true,
image: true,
},
},
},
},
},
});
return { response: collections, status: 200 };
}
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import { Collection, UsersAndCollections } from "@prisma/client";
export default async function updateCollection( export default async function updateCollection(
userId: number, userId: number,
@@ -18,26 +19,6 @@ export default async function updateCollection(
if (!(collectionIsAccessible?.ownerId === userId)) if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
if (data.parentId) {
const findParentCollection = await prisma.collection.findUnique({
where: {
id: data.parentId,
},
select: {
ownerId: true,
},
});
if (
findParentCollection?.ownerId !== userId ||
typeof data.parentId !== "number"
)
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
}
const updatedCollection = await prisma.$transaction(async () => { const updatedCollection = await prisma.$transaction(async () => {
await prisma.usersAndCollections.deleteMany({ await prisma.usersAndCollections.deleteMany({
where: { where: {
@@ -57,11 +38,6 @@ export default async function updateCollection(
description: data.description, description: data.description,
color: data.color, color: data.color,
isPublic: data.isPublic, isPublic: data.isPublic,
parent: {
connect: {
id: data.parentId || undefined,
},
},
members: { members: {
create: data.members.map((e) => ({ create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } }, user: { connect: { id: e.user.id || e.userId } },
@@ -12,26 +12,6 @@ export default async function postCollection(
status: 400, status: 400,
}; };
if (collection.parentId) {
const findParentCollection = await prisma.collection.findUnique({
where: {
id: collection.parentId,
},
select: {
ownerId: true,
},
});
if (
findParentCollection?.ownerId !== userId ||
typeof collection.parentId !== "number"
)
return {
response: "You are not authorized to create a sub-collection here.",
status: 403,
};
}
const findCollection = await prisma.user.findUnique({ const findCollection = await prisma.user.findUnique({
where: { where: {
id: userId, id: userId,
@@ -48,10 +28,7 @@ export default async function postCollection(
const checkIfCollectionExists = findCollection?.collections[0]; const checkIfCollectionExists = findCollection?.collections[0];
if (checkIfCollectionExists) if (checkIfCollectionExists)
return { return { response: "Collection already exists.", status: 400 };
response: "Oops! There's already a Collection with that name.",
status: 400,
};
const newCollection = await prisma.collection.create({ const newCollection = await prisma.collection.create({
data: { data: {
@@ -63,13 +40,6 @@ export default async function postCollection(
name: collection.name.trim(), name: collection.name.trim(),
description: collection.description, description: collection.description,
color: collection.color, color: collection.color,
parent: collection.parentId
? {
connect: {
id: collection.parentId,
},
}
: undefined,
}, },
include: { include: {
_count: { _count: {
+6
View File
@@ -67,6 +67,12 @@ export default async function postLink(
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined; const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
if (validatedUrl === null)
return {
response: "Something went wrong while retrieving the file size.",
status: 400,
};
const contentType = validatedUrl?.get("content-type"); const contentType = validatedUrl?.get("content-type");
let linkType = "url"; let linkType = "url";
let imageExtension = "png"; let imageExtension = "png";
-21
View File
@@ -1,21 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function getToken(userId: number) {
const getTokens = await prisma.accessToken.findMany({
where: {
userId,
revoked: false,
},
select: {
id: true,
name: true,
expires: true,
createdAt: true,
},
});
return {
response: getTokens,
status: 200,
};
}
-92
View File
@@ -1,92 +0,0 @@
import { prisma } from "@/lib/api/db";
import { TokenExpiry } from "@/types/global";
import crypto from "crypto";
import { decode, encode } from "next-auth/jwt";
export default async function postToken(
body: {
name: string;
expires: TokenExpiry;
},
userId: number
) {
console.log(body);
const checkHasEmptyFields = !body.name || body.expires === undefined;
if (checkHasEmptyFields)
return {
response: "Please fill out all the fields.",
status: 400,
};
const checkIfTokenExists = await prisma.accessToken.findFirst({
where: {
name: body.name,
revoked: false,
userId,
},
});
if (checkIfTokenExists) {
return {
response: "Token with that name already exists.",
status: 400,
};
}
const now = Date.now();
let expiryDate = new Date();
const oneDayInSeconds = 86400;
let expiryDateSecond = 7 * oneDayInSeconds;
if (body.expires === TokenExpiry.oneMonth) {
expiryDate.setDate(expiryDate.getDate() + 30);
expiryDateSecond = 30 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.twoMonths) {
expiryDate.setDate(expiryDate.getDate() + 60);
expiryDateSecond = 60 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.threeMonths) {
expiryDate.setDate(expiryDate.getDate() + 90);
expiryDateSecond = 90 * oneDayInSeconds;
} else if (body.expires === TokenExpiry.never) {
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
expiryDateSecond = 73050 * oneDayInSeconds;
} else {
expiryDate.setDate(expiryDate.getDate() + 7);
expiryDateSecond = 7 * oneDayInSeconds;
}
const token = await encode({
token: {
id: userId,
iat: now / 1000,
exp: (expiryDate as any) / 1000,
jti: crypto.randomUUID(),
},
maxAge: expiryDateSecond || 604800,
secret: process.env.NEXTAUTH_SECRET,
});
const tokenBody = await decode({
token,
secret: process.env.NEXTAUTH_SECRET,
});
const createToken = await prisma.accessToken.create({
data: {
name: body.name,
userId,
token: tokenBody?.jti as string,
expires: expiryDate,
},
});
return {
response: {
secretKey: token,
token: createToken,
},
status: 200,
};
}
@@ -1,24 +0,0 @@
import { prisma } from "@/lib/api/db";
export default async function deleteToken(userId: number, tokenId: number) {
if (!tokenId)
return { response: "Please choose a valid token.", status: 401 };
const tokenExists = await prisma.accessToken.findFirst({
where: {
id: tokenId,
userId,
},
});
const revokedToken = await prisma.accessToken.update({
where: {
id: tokenExists?.id,
},
data: {
revoked: true,
},
});
return { response: revokedToken, status: 200 };
}
-36
View File
@@ -1,36 +0,0 @@
import { NextApiRequest } from "next";
import { JWT, getToken } from "next-auth/jwt";
import { prisma } from "./db";
type Props = {
req: NextApiRequest;
};
export default async function verifyToken({
req,
}: Props): Promise<JWT | string> {
const token = await getToken({ req });
const userId = token?.id;
if (!userId) {
return "You must be logged in.";
}
if (token.exp < Date.now() / 1000) {
return "Your session has expired, please log in again.";
}
// check if token is revoked
const revoked = await prisma.accessToken.findFirst({
where: {
token: token.jti,
revoked: true,
},
});
if (revoked) {
return "Your session has expired, please log in again.";
}
return token;
}
+5 -6
View File
@@ -1,8 +1,8 @@
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
import { prisma } from "./db"; import { prisma } from "./db";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import verifySubscription from "./verifySubscription"; import verifySubscription from "./verifySubscription";
import verifyToken from "./verifyToken";
type Props = { type Props = {
req: NextApiRequest; req: NextApiRequest;
@@ -15,15 +15,14 @@ export default async function verifyUser({
req, req,
res, res,
}: Props): Promise<User | null> { }: Props): Promise<User | null> {
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = token?.id;
if (typeof token === "string") { if (!userId) {
res.status(401).json({ response: token }); res.status(401).json({ response: "You must be logged in." });
return null; return null;
} }
const userId = token?.id;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: userId, id: userId,
-20
View File
@@ -1,20 +0,0 @@
export function isPWA() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
(window.navigator as any).standalone ||
document.referrer.includes("android-app://")
);
}
export function isIphone() {
return /iPhone/.test(navigator.userAgent) && !(window as any).MSStream;
}
export function dropdownTriggerer(e: any) {
let targetEl = e.currentTarget;
if (targetEl && targetEl.matches(":focus")) {
setTimeout(function () {
targetEl.blur();
}, 0);
}
}
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "linkwarden", "name": "linkwarden",
"version": "2.4.8", "version": "2.4.7",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git", "repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>", "author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -59,7 +59,6 @@
"react-image-file-resizer": "^0.4.8", "react-image-file-resizer": "^0.4.8",
"react-select": "^5.7.4", "react-select": "^5.7.4",
"stripe": "^12.13.0", "stripe": "^12.13.0",
"vaul": "^0.8.8",
"zustand": "^4.3.8" "zustand": "^4.3.8"
}, },
"devDependencies": { "devDependencies": {
+55 -54
View File
@@ -7,63 +7,64 @@ import Head from "next/head";
import AuthRedirect from "@/layouts/AuthRedirect"; import AuthRedirect from "@/layouts/AuthRedirect";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { isPWA } from "@/lib/client/utils";
export default function App({ export default function App({
Component, Component,
pageProps, pageProps,
}: AppProps<{ }: AppProps<{
session: Session; session: Session;
}>) { }>) {
useEffect(() => {
if (isPWA()) {
const meta = document.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width, initial-scale=1, maximum-scale=1";
document.getElementsByTagName("head")[0].appendChild(meta);
}
}, []);
return ( useEffect(() => {
<SessionProvider let theme = localStorage.getItem("theme");
session={pageProps.session} if (!theme || !theme.includes("-")) {
refetchOnWindowFocus={false} theme = "default-dark"; // Default theme
basePath="/api/v1/auth" localStorage.setItem("theme", theme);
> }
<Head>
<title>Linkwarden</title> document.documentElement.setAttribute('data-theme', theme);
<meta name="viewport" content="width=device-width, initial-scale=1" /> }, []);
<meta name="theme-color" content="#000000" />
<link
rel="apple-touch-icon" return (
sizes="180x180" <SessionProvider
href="/apple-touch-icon.png" session={pageProps.session}
/> refetchOnWindowFocus={false}
<link basePath="/api/v1/auth"
rel="icon" >
type="image/png" <Head>
sizes="32x32" <title>Linkwarden</title>
href="/favicon-32x32.png" <meta name="viewport" content="width=device-width, initial-scale=1" />
/> <link
<link rel="apple-touch-icon"
rel="icon" sizes="180x180"
type="image/png" href="/apple-touch-icon.png"
sizes="16x16" />
href="/favicon-16x16.png" <link
/> rel="icon"
<link rel="manifest" href="/site.webmanifest" /> type="image/png"
</Head> sizes="32x32"
<AuthRedirect> href="/favicon-32x32.png"
<Toaster />
position="top-center" <link
reverseOrder={false} rel="icon"
toastOptions={{ type="image/png"
className: sizes="16x16"
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white", href="/favicon-16x16.png"
}} />
/> <link rel="manifest" href="/site.webmanifest" />
<Component {...pageProps} /> </Head>
</AuthRedirect> <AuthRedirect>
</SessionProvider> <Toaster
); position="top-center"
reverseOrder={false}
toastOptions={{
className:
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
}}
/>
<Component {...pageProps} />
</AuthRedirect>
</SessionProvider>
);
} }
+3 -3
View File
@@ -1,5 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { ArchivedFormat } from "@/types/global"; import { ArchivedFormat } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
@@ -8,7 +9,6 @@ import { UsersAndCollections } from "@prisma/client";
import formidable from "formidable"; import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import fs from "fs"; import fs from "fs";
import verifyToken from "@/lib/api/verifyToken";
export const config = { export const config = {
api: { api: {
@@ -33,8 +33,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.status(401).json({ response: "Invalid parameters." }); return res.status(401).json({ response: "Invalid parameters." });
if (req.method === "GET") { if (req.method === "GET") {
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = typeof token === "string" ? undefined : token?.id; const userId = token?.id;
const collectionIsAccessible = await prisma.collection.findFirst({ const collectionIsAccessible = await prisma.collection.findFirst({
where: { where: {
+55 -56
View File
@@ -65,7 +65,6 @@ import ZitadelProvider from "next-auth/providers/zitadel";
import ZohoProvider from "next-auth/providers/zoho"; import ZohoProvider from "next-auth/providers/zoho";
import ZoomProvider from "next-auth/providers/zoom"; import ZoomProvider from "next-auth/providers/zoom";
import * as process from "process"; import * as process from "process";
import type { NextApiRequest, NextApiResponse } from "next";
const emailEnabled = const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
@@ -1060,60 +1059,60 @@ if (process.env.NEXT_PUBLIC_ZOOM_ENABLED_ENABLED === "true") {
}; };
} }
export default async function auth(req: NextApiRequest, res: NextApiResponse) { export const authOptions: AuthOptions = {
return await NextAuth(req, res, { adapter: adapter as Adapter,
adapter: adapter as Adapter, session: {
session: { strategy: "jwt",
strategy: "jwt", maxAge: 30 * 24 * 60 * 60, // 30 days
maxAge: 30 * 24 * 60 * 60, // 30 days },
}, providers,
providers, pages: {
pages: { signIn: "/login",
signIn: "/login", verifyRequest: "/confirmation",
verifyRequest: "/confirmation", },
}, callbacks: {
callbacks: { async signIn({ user, account, profile, email, credentials }) {
async signIn({ user, account, profile, email, credentials }) { if (account?.provider !== "credentials") {
if (account?.provider !== "credentials") { // registration via SSO can be separately disabled
// registration via SSO can be separately disabled const existingUser = await prisma.account.findFirst({
const existingUser = await prisma.account.findFirst({ where: {
where: { providerAccountId: account?.providerAccountId,
providerAccountId: account?.providerAccountId, },
}, });
}); if (existingUser && newSsoUsersDisabled) {
if (existingUser && newSsoUsersDisabled) { return false;
return false;
}
} }
return true; }
}, return true;
async jwt({ token, trigger, user }) {
token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
return token;
},
async session({ session, token }) {
session.user.id = token.id;
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
subscriptions: true,
},
});
if (user) {
const subscribedUser = await verifySubscription(user);
}
}
return session;
},
}, },
}); async jwt({ token, trigger, user }) {
} token.sub = token.sub ? Number(token.sub) : undefined;
if (trigger === "signIn" || trigger === "signUp")
token.id = user?.id as number;
return token;
},
async session({ session, token }) {
session.user.id = token.id;
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
id: token.id,
},
include: {
subscriptions: true,
},
});
if (user) {
const subscribedUser = await verifySubscription(user);
}
}
return session;
},
},
};
export default NextAuth(authOptions);
+3 -3
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import readFile from "@/lib/api/storage/readFile"; import readFile from "@/lib/api/storage/readFile";
import verifyToken from "@/lib/api/verifyToken"; import { getToken } from "next-auth/jwt";
export default async function Index(req: NextApiRequest, res: NextApiResponse) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const queryId = Number(req.query.id); const queryId = Number(req.query.id);
@@ -12,8 +12,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.status(401) .status(401)
.send("Invalid parameters."); .send("Invalid parameters.");
const token = await verifyToken({ req }); const token = await getToken({ req });
const userId = typeof token === "string" ? undefined : token?.id; const userId = token?.id;
if (req.method === "GET") { if (req.method === "GET") {
const targetUser = await prisma.user.findUnique({ const targetUser = await prisma.user.findUnique({
+10 -11
View File
@@ -1,5 +1,4 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import getCollectionById from "@/lib/api/controllers/collections/collectionId/getCollectionById";
import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById"; import updateCollectionById from "@/lib/api/controllers/collections/collectionId/updateCollectionById";
import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById"; import deleteCollectionById from "@/lib/api/controllers/collections/collectionId/deleteCollectionById";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
@@ -11,18 +10,18 @@ export default async function collections(
const user = await verifyUser({ req, res }); const user = await verifyUser({ req, res });
if (!user) return; if (!user) return;
const collectionId = Number(req.query.id); if (req.method === "PUT") {
const updated = await updateCollectionById(
if (req.method === "GET") { user.id,
const collections = await getCollectionById(user.id, collectionId); Number(req.query.id) as number,
return res req.body
.status(collections.status) );
.json({ response: collections.response });
} else if (req.method === "PUT") {
const updated = await updateCollectionById(user.id, collectionId, req.body);
return res.status(updated.status).json({ response: updated.response }); return res.status(updated.status).json({ response: updated.response });
} else if (req.method === "DELETE") { } else if (req.method === "DELETE") {
const deleted = await deleteCollectionById(user.id, collectionId); const deleted = await deleteCollectionById(
user.id,
Number(req.query.id) as number
);
return res.status(deleted.status).json({ response: deleted.response }); return res.status(deleted.status).json({ response: deleted.response });
} }
} }
+3 -3
View File
@@ -1,10 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser"; import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser";
import verifyToken from "@/lib/api/verifyToken"; import { getToken } from "next-auth/jwt";
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await verifyToken({ req }); const token = await getToken({ req });
const requestingId = typeof token === "string" ? undefined : token?.id; const requestingId = token?.id;
const lookupId = req.query.id as string; const lookupId = req.query.id as string;
-13
View File
@@ -1,13 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import deleteToken from "@/lib/api/controllers/tokens/tokenId/deleteTokenById";
export default async function token(req: NextApiRequest, res: NextApiResponse) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "DELETE") {
const deleted = await deleteToken(user.id, Number(req.query.id) as number);
return res.status(deleted.status).json({ response: deleted.response });
}
}
-20
View File
@@ -1,20 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyUser from "@/lib/api/verifyUser";
import postToken from "@/lib/api/controllers/tokens/postToken";
import getTokens from "@/lib/api/controllers/tokens/getTokens";
export default async function tokens(
req: NextApiRequest,
res: NextApiResponse
) {
const user = await verifyUser({ req, res });
if (!user) return;
if (req.method === "POST") {
const token = await postToken(JSON.parse(req.body), user.id);
return res.status(token.status).json({ response: token.response });
} else if (req.method === "GET") {
const token = await getTokens(user.id);
return res.status(token.status).json({ response: token.response });
}
}
+6 -8
View File
@@ -2,22 +2,20 @@ import type { NextApiRequest, NextApiResponse } from "next";
import getUserById from "@/lib/api/controllers/users/userId/getUserById"; import getUserById from "@/lib/api/controllers/users/userId/getUserById";
import updateUserById from "@/lib/api/controllers/users/userId/updateUserById"; import updateUserById from "@/lib/api/controllers/users/userId/updateUserById";
import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById"; import deleteUserById from "@/lib/api/controllers/users/userId/deleteUserById";
import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifySubscription from "@/lib/api/verifySubscription"; import verifySubscription from "@/lib/api/verifySubscription";
import verifyToken from "@/lib/api/verifyToken";
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function users(req: NextApiRequest, res: NextApiResponse) { export default async function users(req: NextApiRequest, res: NextApiResponse) {
const token = await verifyToken({ req }); const token = await getToken({ req });
if (typeof token === "string") {
res.status(401).json({ response: token });
return null;
}
const userId = token?.id; const userId = token?.id;
if (!userId) {
return res.status(401).json({ response: "You must be logged in." });
}
if (userId !== Number(req.query.id)) if (userId !== Number(req.query.id))
return res.status(401).json({ response: "Permission denied." }); return res.status(401).json({ response: "Permission denied." });
+8 -56
View File
@@ -23,9 +23,6 @@ import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils";
import Link from "next/link";
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
export default function Index() { export default function Index() {
const { settings } = useLocalSettingsStore(); const { settings } = useLocalSettingsStore();
@@ -84,7 +81,6 @@ export default function Index() {
}, [activeCollection]); }, [activeCollection]);
const [editCollectionModal, setEditCollectionModal] = useState(false); const [editCollectionModal, setEditCollectionModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [editCollectionSharingModal, setEditCollectionSharingModal] = const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false); useState(false);
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false); const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
@@ -105,12 +101,14 @@ export default function Index() {
return ( return (
<MainLayout> <MainLayout>
<div <div
className="h-[60rem] p-5 flex gap-3 flex-col" className="h-[60rem] p-5 flex gap-3 flex-col"
style={{ style={{
backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${ backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${
settings.theme === "dark" ? "#262626" : "#f3f4f6" (settings.theme || "default-light").endsWith("-dark") ? "#262626" : "#f3f4f6"
} 13rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`, } 13rem, ${
}} (settings.theme || "default-light").endsWith("-dark") ? "#171717" : "#ffffff"
} 100%)`,
}}
> >
{activeCollection && ( {activeCollection && (
<div className="flex gap-3 items-start justify-between"> <div className="flex gap-3 items-start justify-between">
@@ -129,7 +127,6 @@ export default function Index() {
<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>
@@ -163,20 +160,6 @@ export default function Index() {
: "View Team"} : "View Team"}
</div> </div>
</li> </li>
{permissions === true ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
>
Create Sub-Collection
</div>
</li>
) : undefined}
<li> <li>
<div <div
role="button" role="button"
@@ -245,31 +228,6 @@ export default function Index() {
<p>{activeCollection?.description}</p> <p>{activeCollection?.description}</p>
) : undefined} ) : undefined}
{/* {collections.some((e) => e.parentId === activeCollection.id) ? (
<fieldset className="border rounded-md p-2 border-neutral-content">
<legend className="text-sm ml-2">Sub-Collections</legend>
<div className="flex gap-3">
{collections
.filter((e) => e.parentId === activeCollection?.id)
.map((e, i) => {
return (
<Link
key={i}
className="flex gap-1 items-center btn btn-ghost btn-sm"
href={`/collections/${e.id}`}
>
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: e.color }}
></i>
<p className="text-xs">{e.name}</p>
</Link>
);
})}
</div>
</fieldset>
) : undefined} */}
<div className="divider my-0"></div> <div className="divider my-0"></div>
<div className="flex justify-between items-end gap-5"> <div className="flex justify-between items-end gap-5">
@@ -304,12 +262,6 @@ export default function Index() {
activeCollection={activeCollection} activeCollection={activeCollection}
/> />
) : undefined} ) : undefined}
{newCollectionModal ? (
<NewCollectionModal
onClose={() => setNewCollectionModal(false)}
parent={activeCollection}
/>
) : undefined}
{deleteCollectionModal ? ( {deleteCollectionModal ? (
<DeleteCollectionModal <DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)} onClose={() => setDeleteCollectionModal(false)}
+2 -1
View File
@@ -11,6 +11,7 @@ import PageHeader from "@/components/PageHeader";
export default function Collections() { export default function Collections() {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const [expandDropdown, setExpandDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [sortedCollections, setSortedCollections] = useState(collections); const [sortedCollections, setSortedCollections] = useState(collections);
@@ -39,7 +40,7 @@ export default function Collections() {
<div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid min-[1900px]:grid-cols-4 2xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
{sortedCollections {sortedCollections
.filter((e) => e.ownerId === data?.user.id && e.parentId === null) .filter((e) => e.ownerId === data?.user.id)
.map((e, i) => { .map((e, i) => {
return <CollectionCard key={i} collection={e} />; return <CollectionCard key={i} collection={e} />;
})} })}
+259 -257
View File
@@ -2,6 +2,7 @@ import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import useTagStore from "@/store/tags"; import useTagStore from "@/store/tags";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import LinkCard from "@/components/LinkViews/LinkCard";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import Link from "next/link"; import Link from "next/link";
@@ -15,295 +16,296 @@ import PageHeader from "@/components/PageHeader";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import ViewDropdown from "@/components/ViewDropdown"; import ViewDropdown from "@/components/ViewDropdown";
import { dropdownTriggerer } from "@/lib/client/utils";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
export default function Dashboard() { export default function Dashboard() {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { links } = useLinkStore(); const { links } = useLinkStore();
const { tags } = useTagStore(); const { tags } = useTagStore();
const [numberOfLinks, setNumberOfLinks] = useState(0); const [numberOfLinks, setNumberOfLinks] = useState(0);
const [showLinks, setShowLinks] = useState(3); const [showLinks, setShowLinks] = useState(3);
useLinks({ pinnedOnly: true, sort: 0 }); useLinks({ pinnedOnly: true, sort: 0 });
useEffect(() => { useEffect(() => {
setNumberOfLinks( setNumberOfLinks(
collections.reduce( collections.reduce(
(accumulator, collection) => (accumulator, collection) =>
accumulator + (collection._count as any).links, accumulator + (collection._count as any).links,
0 0
) )
); );
}, [collections]); }, [collections]);
const handleNumberOfLinksToShow = () => { const handleNumberOfLinksToShow = () => {
if (window.innerWidth > 1900) { if (window.innerWidth > 1900) {
setShowLinks(8); setShowLinks(8);
} else if (window.innerWidth > 1280) { } else if (window.innerWidth > 1280) {
setShowLinks(6); setShowLinks(6);
} else if (window.innerWidth > 650) { } else if (window.innerWidth > 650) {
setShowLinks(4); setShowLinks(4);
} else setShowLinks(3); } else setShowLinks(3);
}; };
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
useEffect(() => { useEffect(() => {
handleNumberOfLinksToShow(); handleNumberOfLinksToShow();
}, [width]); }, [width]);
const importBookmarks = async (e: any, format: MigrationFormat) => { const importBookmarks = async (e: any, format: MigrationFormat) => {
const file: File = e.target.files[0]; const file: File = e.target.files[0];
if (file) { if (file) {
var reader = new FileReader(); var reader = new FileReader();
reader.readAsText(file, "UTF-8"); reader.readAsText(file, "UTF-8");
reader.onload = async function (e) { reader.onload = async function (e) {
const load = toast.loading("Importing..."); const load = toast.loading("Importing...");
const request: string = e.target?.result as string; const request: string = e.target?.result as string;
const body: MigrationRequest = { const body: MigrationRequest = {
format, format,
data: request, data: request,
}; };
const response = await fetch("/api/v1/migration", { const response = await fetch("/api/v1/migration", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const data = await response.json(); const data = await response.json();
toast.dismiss(load); toast.dismiss(load);
toast.success("Imported the Bookmarks! Reloading the page..."); toast.success("Imported the Bookmarks! Reloading the page...");
setTimeout(() => { setTimeout(() => {
location.reload(); location.reload();
}, 2000); }, 2000);
}; };
reader.onerror = function (e) { reader.onerror = function (e) {
console.log("Error:", e); console.log("Error:", e);
}; };
} }
}; };
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
); );
const linkView = { const linkView = {
[ViewMode.Card]: CardView, [ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView, // [ViewMode.Grid]: GridView,
[ViewMode.List]: ListView, [ViewMode.List]: ListView,
}; };
// @ts-ignore // @ts-ignore
const LinkComponent = linkView[viewMode]; const LinkComponent = linkView[viewMode];
return ( return (
<MainLayout> <MainLayout>
<div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5"> <div style={{ flex: "1 1 auto" }} className="p-5 flex flex-col gap-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<PageHeader <PageHeader
icon={"bi-house "} icon={"bi-house "}
title={"Dashboard"} title={"Dashboard"}
description={"A brief overview of your data"} description={"A brief overview of your data"}
/> />
<ViewDropdown viewMode={viewMode} setViewMode={setViewMode} /> <ViewDropdown viewMode={viewMode} setViewMode={setViewMode} />
</div>
<div>
<div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
<DashboardItem
name={numberOfLinks === 1 ? "Link" : "Links"}
value={numberOfLinks}
icon={"bi-link-45deg"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={collections.length === 1 ? "Collection" : "Collections"}
value={collections.length}
icon={"bi-folder"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={tags.length === 1 ? "Tag" : "Tags"}
value={tags.length}
icon={"bi-hash"}
/>
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<PageHeader
icon={"bi-clock-history"}
title={"Recent"}
description={"Recently added Links"}
/>
</div>
<Link
href="/links"
className="flex items-center text-sm text-black/75 dark:text-white/75 gap-2 cursor-pointer"
>
View All
<i className="bi-chevron-right text-sm"></i>
</Link>
</div>
<div
style={{ flex: "0 1 auto" }}
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
>
{links[0] ? (
<div className="w-full">
<LinkComponent links={links.slice(0, showLinks)} />
</div> </div>
) : (
<div>
<div className="flex justify-evenly flex-col xl:flex-row xl:items-center gap-2 xl:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
<DashboardItem
name={numberOfLinks === 1 ? "Link" : "Links"}
value={numberOfLinks}
icon={"bi-link-45deg"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={collections.length === 1 ? "Collection" : "Collections"}
value={collections.length}
icon={"bi-folder"}
/>
<div className="divider xl:divider-horizontal"></div>
<DashboardItem
name={tags.length === 1 ? "Tag" : "Tags"}
value={tags.length}
icon={"bi-hash"}
/>
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<PageHeader
icon={"bi-clock-history"}
title={"Recent"}
description={"Recently added Links"}
/>
</div>
<Link
href="/links"
className="flex items-center text-sm text-neutral gap-2 cursor-pointer"
>
View All
<i className="bi-chevron-right text-sm"></i>
</Link>
</div>
<div <div
style={{ flex: "1 1 auto" }} style={{ flex: "0 1 auto" }}
className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200" className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
> >
<p className="text-center text-2xl"> {links[0] ? (
View Your Recently Added Links Here! <div className="w-full">
</p> <LinkComponent links={links.slice(0, showLinks)} />
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
This section will view your latest added Links across every
Collections you have access to.
</p>
<div className="text-center w-full mt-4 flex flex-wrap gap-4 justify-center">
<div
onClick={() => {
setNewLinkModal(true);
}}
className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-violet-400 text-white"
>
<i className="bi-plus-lg text-xl duration-100"></i>
<span className="group-hover:opacity-0 text-right duration-100">
Add New Link
</span>
</div>
<div className="dropdown dropdown-bottom">
<div
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral"
id="import-dropdown"
>
<i className="bi-cloud-upload text-xl duration-100"></i>
<p>Import From</p>
</div> </div>
<ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60"> ) : (
<li> <div
<label style={{ flex: "1 1 auto" }}
tabIndex={0} className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"
role="button" >
htmlFor="import-linkwarden-file" <p className="text-center text-2xl">
title="JSON File" View Your Recently Added Links Here!
> </p>
From Linkwarden <p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
<input This section will view your latest added Links across every
type="file" Collections you have access to.
name="photo" </p>
id="import-linkwarden-file"
accept=".json"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.linkwarden)
}
/>
</label>
</li>
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-html-file"
title="HTML File"
>
From Bookmarks HTML file
<input
type="file"
name="photo"
id="import-html-file"
accept=".html"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.htmlFile)
}
/>
</label>
</li>
</ul>
</div>
</div>
</div>
)}
</div>
<div className="flex justify-between items-center"> <div className="text-center w-full mt-4 flex flex-wrap gap-4 justify-center">
<div className="flex gap-2 items-center"> <div
<PageHeader onClick={() => {
icon={"bi-pin-angle"} setNewLinkModal(true);
title={"Pinned"} }}
description={"Your pinned Links"} className="inline-flex items-center gap-2 text-sm btn btn-accent dark:border-violet-400 text-white"
/> >
</div> <i className="bi-plus-lg text-xl duration-100"></i>
<Link <span className="group-hover:opacity-0 text-right duration-100">
href="/links/pinned" Add New Link
className="flex items-center text-sm text-black/75 dark:text-white/75 gap-2 cursor-pointer" </span>
> </div>
View All
<i className="bi-chevron-right text-sm "></i>
</Link>
</div>
<div <div className="dropdown dropdown-bottom">
style={{ flex: "1 1 auto" }} <div
className="flex flex-col 2xl:flex-row items-start 2xl:gap-2" tabIndex={0}
> role="button"
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( className="inline-flex items-center gap-2 text-sm btn btn-outline btn-neutral"
<div className="w-full"> id="import-dropdown"
<LinkComponent >
links={links <i className="bi-cloud-upload text-xl duration-100"></i>
.filter((e) => e.pinnedBy && e.pinnedBy[0]) <p>Import From</p>
.slice(0, showLinks)} </div>
/> <ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60">
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-linkwarden-file"
title="JSON File"
>
From Linkwarden
<input
type="file"
name="photo"
id="import-linkwarden-file"
accept=".json"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.linkwarden)
}
/>
</label>
</li>
<li>
<label
tabIndex={0}
role="button"
htmlFor="import-html-file"
title="HTML File"
>
From Bookmarks HTML file
<input
type="file"
name="photo"
id="import-html-file"
accept=".html"
className="hidden"
onChange={(e) =>
importBookmarks(e, MigrationFormat.htmlFile)
}
/>
</label>
</li>
</ul>
</div>
</div>
</div>
)}
</div> </div>
) : (
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<PageHeader
icon={"bi-pin-angle"}
title={"Pinned"}
description={"Your pinned Links"}
/>
</div>
<Link
href="/links/pinned"
className="flex items-center text-sm text-neutral gap-2 cursor-pointer"
>
View All
<i className="bi-chevron-right text-sm "></i>
</Link>
</div>
<div <div
style={{ flex: "1 1 auto" }} style={{ flex: "1 1 auto" }}
className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200" className="flex flex-col 2xl:flex-row items-start 2xl:gap-2"
> >
<p className="text-center text-2xl"> {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
Pin Your Favorite Links Here! <div className="w-full">
</p> <div
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2"> className={`grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5 w-full`}
You can Pin your favorite Links by clicking on the three dots on >
each Link and clicking{" "} {links
<span className="font-semibold">Pin to Dashboard</span>. .filter((e) => e.pinnedBy && e.pinnedBy[0])
</p> .map((e, i) => <LinkCard key={i} link={e} count={i} />)
.slice(0, showLinks)}
</div>
</div>
) : (
<div
style={{ flex: "1 1 auto" }}
className="sky-shadow flex flex-col justify-center h-full border border-solid border-neutral-content w-full mx-auto p-10 rounded-2xl bg-base-200"
>
<p className="text-center text-2xl">
Pin Your Favorite Links Here!
</p>
<p className="text-center mx-auto max-w-96 w-fit text-neutral text-sm mt-2">
You can Pin your favorite Links by clicking on the three dots on
each Link and clicking{" "}
<span className="font-semibold">Pin to Dashboard</span>.
</p>
</div>
)}
</div> </div>
)} </div>
</div> {newLinkModal ? (
</div> <NewLinkModal onClose={() => setNewLinkModal(false)} />
{newLinkModal ? ( ) : undefined}
<NewLinkModal onClose={() => setNewLinkModal(false)} /> </MainLayout>
) : undefined} );
</MainLayout>
);
} }
-7
View File
@@ -170,13 +170,6 @@ export default function Login({
{displayLoginCredential()} {displayLoginCredential()}
{displayLoginExternalButton()} {displayLoginExternalButton()}
{displayRegistration()} {displayRegistration()}
<Link
href="https://docs.linkwarden.app/getting-started/pwa-installation"
className="underline text-center"
target="_blank"
>
You can install Linkwarden onto your device
</Link>
</div> </div>
</form> </form>
</CenteredForm> </CenteredForm>
+2
View File
@@ -25,6 +25,8 @@ export default function Search() {
tags: true, tags: true,
}); });
const [filterDropdown, setFilterDropdown] = useState(false);
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
localStorage.getItem("viewMode") || ViewMode.Card localStorage.getItem("viewMode") || ViewMode.Card
); );
-107
View File
@@ -1,107 +0,0 @@
import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useEffect, useState } from "react";
import NewTokenModal from "@/components/ModalContent/NewTokenModal";
import RevokeTokenModal from "@/components/ModalContent/RevokeTokenModal";
import { AccessToken } from "@prisma/client";
import useTokenStore from "@/store/tokens";
export default function AccessTokens() {
const [newTokenModal, setNewTokenModal] = useState(false);
const [revokeTokenModal, setRevokeTokenModal] = useState(false);
const [selectedToken, setSelectedToken] = useState<AccessToken | null>(null);
const openRevokeModal = (token: AccessToken) => {
setSelectedToken(token);
setRevokeTokenModal(true);
};
const { setTokens, tokens } = useTokenStore();
useEffect(() => {
fetch("/api/v1/tokens")
.then((res) => res.json())
.then((data) => {
if (data.response) setTokens(data.response as AccessToken[]);
});
}, []);
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Access Tokens</p>
<div className="divider my-3"></div>
<div className="flex flex-col gap-3">
<p>
Access Tokens can be used to access Linkwarden from other apps and
services without giving away your Username and Password.
</p>
<button
className={`btn ml-auto btn-accent dark:border-violet-400 text-white tracking-wider w-fit flex items-center gap-2`}
onClick={() => {
setNewTokenModal(true);
}}
>
New Access Token
</button>
{tokens.length > 0 ? (
<>
<div className="divider my-0"></div>
<table className="table">
{/* head */}
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Created</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{tokens.map((token, i) => (
<React.Fragment key={i}>
<tr>
<th>{i + 1}</th>
<td>{token.name}</td>
<td>
{new Date(token.createdAt || "").toLocaleDateString()}
</td>
<td>
{new Date(token.expires || "").toLocaleDateString()}
</td>
<td>
<button
className="btn btn-sm btn-ghost btn-square hover:bg-red-500"
onClick={() => openRevokeModal(token as AccessToken)}
>
<i className="bi-x text-lg"></i>
</button>
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
</>
) : undefined}
</div>
{newTokenModal ? (
<NewTokenModal onClose={() => setNewTokenModal(false)} />
) : undefined}
{revokeTokenModal && selectedToken && (
<RevokeTokenModal
onClose={() => {
setRevokeTokenModal(false);
setSelectedToken(null);
}}
activeToken={selectedToken}
/>
)}
</SettingsLayout>
);
}
+5 -7
View File
@@ -11,7 +11,6 @@ import React from "react";
import { MigrationFormat, MigrationRequest } from "@/types/global"; import { MigrationFormat, MigrationRequest } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
import Checkbox from "@/components/Checkbox"; import Checkbox from "@/components/Checkbox";
import { dropdownTriggerer } from "@/lib/client/utils";
export default function Account() { export default function Account() {
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
@@ -192,8 +191,8 @@ export default function Account() {
) : undefined} ) : undefined}
</div> </div>
<div className="sm:row-span-2 sm:justify-self-center my-3"> <div className="sm:row-span-2 sm:justify-self-center mx-auto my-3">
<p className="mb-2 sm:text-center">Profile Photo</p> <p className="mb-2 text-center">Profile Photo</p>
<div className="w-28 h-28 flex items-center justify-center rounded-full relative"> <div className="w-28 h-28 flex items-center justify-center rounded-full relative">
<ProfilePhoto <ProfilePhoto
priority={true} priority={true}
@@ -246,7 +245,6 @@ export default function Account() {
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
onMouseDown={dropdownTriggerer}
className="flex gap-2 text-sm btn btn-outline btn-neutral group" className="flex gap-2 text-sm btn btn-outline btn-neutral group"
id="import-dropdown" id="import-dropdown"
> >
@@ -349,8 +347,8 @@ export default function Account() {
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
label="Save Changes" label="Save"
className="mt-2 w-full sm:w-fit" className="mt-2 mx-auto lg:mx-0"
/> />
<div> <div>
@@ -375,7 +373,7 @@ export default function Account() {
<Link <Link
href="/settings/delete" href="/settings/delete"
className="text-white w-full sm:w-fit flex items-center gap-2 py-2 px-4 rounded-md text-lg tracking-wide select-none font-semibold duration-100 bg-red-500 hover:bg-red-400 cursor-pointer" className="mx-auto lg:mx-0 text-white flex items-center gap-2 py-1 px-3 rounded-md text-lg tracking-wide select-none font-semibold duration-100 w-fit bg-red-500 hover:bg-red-400 cursor-pointer"
> >
<p className="text-center w-full">Delete Your Account</p> <p className="text-center w-full">Delete Your Account</p>
</Link> </Link>
+78
View File
@@ -0,0 +1,78 @@
import Checkbox from "@/components/Checkbox";
import SubmitButton from "@/components/SubmitButton";
import SettingsLayout from "@/layouts/SettingsLayout";
import React, { useEffect, useState } from "react";
import useAccountStore from "@/store/account";
import { toast } from "react-hot-toast";
import { AccountSettings } from "@/types/global";
import TextInput from "@/components/TextInput";
export default function Api() {
const [submitLoader, setSubmitLoader] = useState(false);
const { account, updateAccount } = useAccountStore();
const [user, setUser] = useState<AccountSettings>(account);
const [archiveAsScreenshot, setArchiveAsScreenshot] =
useState<boolean>(false);
const [archiveAsPDF, setArchiveAsPDF] = useState<boolean>(false);
const [archiveAsWaybackMachine, setArchiveAsWaybackMachine] =
useState<boolean>(false);
useEffect(() => {
setUser({
...account,
archiveAsScreenshot,
archiveAsPDF,
archiveAsWaybackMachine,
});
}, [account, archiveAsScreenshot, archiveAsPDF, archiveAsWaybackMachine]);
function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
useEffect(() => {
if (!objectIsEmpty(account)) {
setArchiveAsScreenshot(account.archiveAsScreenshot);
setArchiveAsPDF(account.archiveAsPDF);
setArchiveAsWaybackMachine(account.archiveAsWaybackMachine);
}
}, [account]);
const submit = async () => {
// setSubmitLoader(true);
// const load = toast.loading("Applying...");
// const response = await updateAccount({
// ...user,
// });
// toast.dismiss(load);
// if (response.ok) {
// toast.success("Settings Applied!");
// } else toast.error(response.data as string);
// setSubmitLoader(false);
};
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">API Keys (Soon)</p>
<div className="divider my-3"></div>
<div className="flex flex-col gap-3">
<div className="badge badge-warning rounded-md w-fit">
Status: Under Development
</div>
<p>This page will be for creating and managing your API keys.</p>
<p>
For now, you can <i>temporarily</i> use your{" "}
<code className="text-xs whitespace-nowrap bg-black/40 rounded-md px-2 py-1">
next-auth.session-token
</code>{" "}
in your browser cookies as the API key for your integrations.
</p>
</div>
</SettingsLayout>
);
}
+95 -76
View File
@@ -1,16 +1,16 @@
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import { AccountSettings } from "@/types/global"; import { AccountSettings } from "@/types/global";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import React from "react";
import useLocalSettingsStore from "@/store/localSettings"; import useLocalSettingsStore from "@/store/localSettings";
export default function Appearance() { export default function Appearance() {
const { updateSettings } = useLocalSettingsStore(); const { updateSettings } = useLocalSettingsStore();
const submit = async () => { const { account, updateAccount } = useAccountStore();
const submit = async () => {
setSubmitLoader(true); setSubmitLoader(true);
const load = toast.loading("Applying..."); const load = toast.loading("Applying...");
const response = await updateAccount({ const response = await updateAccount({
@@ -18,89 +18,108 @@ export default function Appearance() {
}); });
toast.dismiss(load); toast.dismiss(load);
if (response.ok) { if (response.ok) {
toast.success("Settings Applied!"); toast.success("Settings Applied!");
} else toast.error(response.data as string); } else toast.error(response.data as string);
setSubmitLoader(false); setSubmitLoader(false);
}; };
const [submitLoader, setSubmitLoader] = useState(false);
const [user, setUser] = useState<AccountSettings>(
!objectIsEmpty(account)
? account
: ({
// @ts-ignore
id: null,
name: "",
username: "",
email: "",
emailVerified: null,
blurredFavicons: null,
image: "",
isPrivate: true,
// @ts-ignore
createdAt: null,
whitelistedUsers: [],
} as unknown as AccountSettings)
);
const [submitLoader, setSubmitLoader] = useState(false); // Combine colorTheme and mode into a single state
const [theme, setTheme] = useState(localStorage.getItem("theme") || "default-dark");
const { account, updateAccount } = useAccountStore(); function objectIsEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
const [user, setUser] = useState<AccountSettings>( useEffect(() => {
!objectIsEmpty(account) if (!objectIsEmpty(account)) setUser({ ...account });
? account }, [account]);
: ({
// @ts-ignore
id: null,
name: "",
username: "",
email: "",
emailVerified: null,
blurredFavicons: null,
image: "",
isPrivate: true,
// @ts-ignore
createdAt: null,
whitelistedUsers: [],
} as unknown as AccountSettings)
);
function objectIsEmpty(obj: object) { const handleThemeChange = (newThemePart: string, isColorTheme: boolean) => {
return Object.keys(obj).length === 0; const currentTheme = localStorage.getItem("theme") || "default-light";
} const [currentColorTheme, currentMode] = currentTheme.split('-');
const newTheme = isColorTheme ? `${newThemePart}-${currentMode}` : `${currentColorTheme}-${newThemePart}`;
useEffect(() => { localStorage.setItem("theme", newTheme);
if (!objectIsEmpty(account)) setUser({ ...account }); document.documentElement.setAttribute('data-theme', newTheme);
}, [account]); updateSettings({ theme: newTheme });
return ( // Update the theme state
<SettingsLayout> setTheme(newTheme);
<p className="capitalize text-3xl font-thin inline">Appearance</p> };
<div className="divider my-3"></div>
<div className="flex flex-col gap-5">
<div>
<p className="mb-3">Select Theme</p>
<div className="flex gap-3 w-full">
<div
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${
localStorage.getItem("theme") === "dark"
? "dark:outline-primary text-primary"
: "text-white"
}`}
onClick={() => updateSettings({ theme: "dark" })}
>
<i className="bi-moon-fill text-6xl"></i>
<p className="ml-2 text-2xl">Dark</p>
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
return (
<SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Appearance</p>
<div className="divider my-3"></div>
<div className="flex flex-col gap-5">
<div>
<p className="mb-3">Select Mode</p>
<div className="grid grid-cols-2 gap-3">
{["light", "dark"].map((modeOption) => (
<button
key={modeOption}
onClick={() => handleThemeChange(modeOption, false)}
className={`w-full text-center h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none ${theme.endsWith(modeOption) ? "ring-2 ring-primary" : "ring-2 ring-neutral"}`}
>
{modeOption === 'light' ?
<i className={`bi-sun-fill text-6xl ${theme.endsWith(modeOption) ? "text-primary" : ""}`}></i> :
<i className={`bi-moon-fill text-6xl ${theme.endsWith(modeOption) ? "text-primary" : ""}`}></i>}
<p className="ml-2 text-2xl">{modeOption.charAt(0).toUpperCase() + modeOption.slice(1)}</p>
</button>
))}
</div>
</div> </div>
<div
className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-white ${
localStorage.getItem("theme") === "light"
? "outline-primary text-primary"
: "text-black"
}`}
onClick={() => updateSettings({ theme: "light" })}
>
<i className="bi-sun-fill text-6xl"></i>
<p className="ml-2 text-2xl">Light</p>
{/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
</div>
</div>
</div>
{/* <SubmitButton <div>
onClick={submit} <p className="mb-3">Select Color Theme</p>
loading={submitLoader} <div className="grid grid-cols-4 gap-3">
label="Save Changes" {["default", "red", "green", "orange"].map((colorTheme) => (
className="mt-2 mx-auto lg:mx-0" <button
/> */} key={colorTheme}
</div> onClick={() => handleThemeChange(colorTheme, true)}
</SettingsLayout> className={`w-full text-center h-36 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none ${theme.startsWith(colorTheme) ? "ring-2 ring-primary" : "ring-2 ring-neutral"}`}
); >
{colorTheme.charAt(0).toUpperCase() + colorTheme.slice(1)}
</button>
))}
</div>
</div>
<button
onClick={submit}
disabled={submitLoader}
className="mt-2 mx-auto lg:mx-0 bg-primary text-white rounded-md px-4 py-2"
>
{submitLoader ? "Saving..." : "Save"}
</button>
</div>
</SettingsLayout>
);
} }
+2 -2
View File
@@ -85,8 +85,8 @@ export default function Archive() {
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
label="Save Changes" label="Save"
className="mt-2 w-full sm:w-fit" className="mt-2 mx-auto lg:mx-0"
/> />
</SettingsLayout> </SettingsLayout>
); );
+2 -2
View File
@@ -77,8 +77,8 @@ export default function Password() {
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
label="Save Changes" label="Save"
className="mt-2 w-full sm:w-fit" className="mt-2 mx-auto lg:mx-0"
/> />
</div> </div>
</SettingsLayout> </SettingsLayout>
-2
View File
@@ -11,7 +11,6 @@ import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import { dropdownTriggerer } from "@/lib/client/utils";
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
@@ -154,7 +153,6 @@ export default function Index() {
<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 <i
@@ -1,8 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_name_key" ON "ApiKey"("name");
@@ -1,14 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[name,userId]` on the table `ApiKey` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "ApiKey_name_key";
-- DropIndex
DROP INDEX "ApiKey_token_userId_key";
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_name_userId_key" ON "ApiKey"("name", "userId");
@@ -1,35 +0,0 @@
/*
Warnings:
- You are about to drop the `ApiKey` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ApiKey" DROP CONSTRAINT "ApiKey_userId_fkey";
-- DropTable
DROP TABLE "ApiKey";
-- CreateTable
CREATE TABLE "AccessToken" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"token" TEXT NOT NULL,
"revoked" BOOLEAN NOT NULL DEFAULT false,
"expires" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_token_key" ON "AccessToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "AccessToken_name_userId_key" ON "AccessToken"("name", "userId");
-- AddForeignKey
ALTER TABLE "AccessToken" ADD CONSTRAINT "AccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- DropIndex
DROP INDEX "AccessToken_name_userId_key";
@@ -1,5 +0,0 @@
-- AlterTable
ALTER TABLE "Collection" ADD COLUMN "parentId" INTEGER;
-- AddForeignKey
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Collection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+15 -17
View File
@@ -39,7 +39,7 @@ model User {
pinnedLinks Link[] pinnedLinks Link[]
collectionsJoined UsersAndCollections[] collectionsJoined UsersAndCollections[]
whitelistedUsers WhitelistedUser[] whitelistedUsers WhitelistedUser[]
accessTokens AccessToken[] apiKeys ApiKey[]
subscriptions Subscription? subscriptions Subscription?
archiveAsScreenshot Boolean @default(true) archiveAsScreenshot Boolean @default(true)
archiveAsPDF Boolean @default(true) archiveAsPDF Boolean @default(true)
@@ -69,20 +69,17 @@ model VerificationToken {
} }
model Collection { model Collection {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
description String @default("") description String @default("")
color String @default("#0ea5e9") color String @default("#0ea5e9")
parentId Int? isPublic Boolean @default(false)
parent Collection? @relation("SubCollections", fields: [parentId], references: [id]) owner User @relation(fields: [ownerId], references: [id])
subCollections Collection[] @relation("SubCollections") ownerId Int
isPublic Boolean @default(false) members UsersAndCollections[]
owner User @relation(fields: [ownerId], references: [id]) links Link[]
ownerId Int createdAt DateTime @default(now())
members UsersAndCollections[] updatedAt DateTime @default(now()) @updatedAt
links Link[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@unique([name, ownerId]) @@unique([name, ownerId])
} }
@@ -145,15 +142,16 @@ model Subscription {
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
} }
model AccessToken { model ApiKey {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int userId Int
token String @unique token String @unique
revoked Boolean @default(false)
expires DateTime expires DateTime
lastUsedAt DateTime? lastUsedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@unique([token, userId])
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 B

After

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 492 KiB

+1 -49
View File
@@ -1,49 +1 @@
{ {"name":"Linkwarden","short_name":"Linkwarden","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
"id": "/dashboard",
"name":"Linkwarden",
"short_name":"Linkwarden",
"icons":[
{
"src":"/android-chrome-192x192.png",
"sizes":"192x192",
"type":"image/png"
},
{
"src":"/android-chrome-512x512.png",
"sizes":"512x512",
"type":"image/png"
},
{
"src": "/logo_maskable.png",
"sizes": "196x196",
"type": "image/png",
"purpose": "maskable"
}
],
"share_target": {
"action": "/api/v1/links/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"url": "link"
}
},
"screenshots": [
{
"src": "/screenshots/screenshot1.png",
"type": "image/png",
"sizes": "386x731"
},
{
"src": "/screenshots/screenshot2.png",
"type": "image/png",
"sizes": "1361x861",
"form_factor": "wide"
}
],
"theme_color":"#000000",
"background_color":"#000000",
"display":"standalone",
"orientation": "portrait",
"start_url": "/dashboard"
}
+1 -1
View File
@@ -1,4 +1,4 @@
import "dotenv/config"; import 'dotenv/config';
import { Collection, Link, User } from "@prisma/client"; import { Collection, Link, User } from "@prisma/client";
import { prisma } from "../lib/api/db"; import { prisma } from "../lib/api/db";
import archiveHandler from "../lib/api/archiveHandler"; import archiveHandler from "../lib/api/archiveHandler";
+1 -5
View File
@@ -78,11 +78,7 @@ const useCollectionStore = create<CollectionStore>()((set) => ({
if (response.ok) { if (response.ok) {
set((state) => ({ set((state) => ({
collections: state.collections.filter( collections: state.collections.filter((e) => e.id !== collectionId),
(collection) =>
collection.id !== collectionId &&
collection.parentId !== collectionId
),
})); }));
useTagStore.getState().setTags(); useTagStore.getState().setTags();
} }
+21 -16
View File
@@ -18,34 +18,39 @@ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
viewMode: "", viewMode: "",
}, },
updateSettings: async (newSettings) => { updateSettings: async (newSettings) => {
if ( if (newSettings.theme) {
newSettings.theme &&
newSettings.theme !== localStorage.getItem("theme")
) {
localStorage.setItem("theme", newSettings.theme); localStorage.setItem("theme", newSettings.theme);
document.documentElement.setAttribute('data-theme', newSettings.theme);
const localTheme = localStorage.getItem("theme") || ""; if (newSettings.theme.endsWith("-dark")) {
document.documentElement.classList.add("dark");
document.querySelector("html")?.setAttribute("data-theme", localTheme); } else {
document.documentElement.classList.remove("dark");
}
} }
if ( if (newSettings.viewMode) {
newSettings.viewMode &&
newSettings.viewMode !== localStorage.getItem("viewMode")
) {
localStorage.setItem("viewMode", newSettings.viewMode); localStorage.setItem("viewMode", newSettings.viewMode);
// const localTheme = localStorage.getItem("viewMode") || "";
} }
set((state) => ({ settings: { ...state.settings, ...newSettings } })); set((state) => ({ settings: { ...state.settings, ...newSettings } }));
}, },
setSettings: async () => { setSettings: async () => {
if (!localStorage.getItem("theme")) { let theme = localStorage.getItem("theme");
localStorage.setItem("theme", "dark"); if (!theme || !theme.includes("-")) {
theme = "default-dark"; // Default theme
localStorage.setItem("theme", theme);
} }
const localTheme = localStorage.getItem("theme") || ""; const localTheme = theme;
document.documentElement.setAttribute('data-theme', localTheme);
if (localTheme.endsWith("-dark")) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
set((state) => ({ set((state) => ({
settings: { ...state.settings, theme: localTheme }, settings: { ...state.settings, theme: localTheme },
-56
View File
@@ -1,56 +0,0 @@
import { AccessToken } from "@prisma/client";
import { create } from "zustand";
// Token store
type ResponseObject = {
ok: boolean;
data: object | string;
};
type TokenStore = {
tokens: Partial<AccessToken>[];
setTokens: (data: Partial<AccessToken>[]) => void;
addToken: (body: Partial<AccessToken>[]) => Promise<ResponseObject>;
revokeToken: (tokenId: number) => Promise<ResponseObject>;
};
const useTokenStore = create<TokenStore>((set) => ({
tokens: [],
setTokens: async (data) => {
set(() => ({
tokens: data,
}));
},
addToken: async (body) => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: [...state.tokens, data.response.token],
}));
return { ok: response.ok, data: data.response };
},
revokeToken: async (tokenId) => {
const response = await fetch(`/api/v1/tokens/${tokenId}`, {
method: "DELETE",
});
const data = await response.json();
if (response.ok)
set((state) => ({
tokens: state.tokens.filter((token) => token.id !== tokenId),
}));
return { ok: response.ok, data: data.response };
},
}));
export default useTokenStore;
+5
View File
@@ -27,6 +27,11 @@
color: var(--selection-color); color: var(--selection-color);
} }
html,
body {
scroll-behavior: smooth;
}
/* Hide scrollbar */ /* Hide scrollbar */
.hide-scrollbar::-webkit-scrollbar { .hide-scrollbar::-webkit-scrollbar {
display: none; display: none;
+121 -18
View File
@@ -5,7 +5,7 @@ module.exports = {
daisyui: { daisyui: {
themes: [ themes: [
{ {
light: { "default-light": {
primary: "#0369a1", primary: "#0369a1",
secondary: "#0891b2", secondary: "#0891b2",
accent: "#6d28d9", accent: "#6d28d9",
@@ -21,7 +21,7 @@ module.exports = {
}, },
}, },
{ {
dark: { "default-dark": {
primary: "#7dd3fc", primary: "#7dd3fc",
secondary: "#22d3ee", secondary: "#22d3ee",
accent: "#6d28d9", accent: "#6d28d9",
@@ -36,21 +36,124 @@ module.exports = {
error: "#f1293c", error: "#f1293c",
}, },
}, },
], // Red Light Theme
}, {
darkMode: ["class", '[data-theme="dark"]'], "red-light": {
content: [ primary: "#ef4444",
"./app/**/*.{js,ts,jsx,tsx}", secondary: "#dc2626",
"./pages/**/*.{js,ts,jsx,tsx}", accent: "#6d28d9",
"./components/**/*.{js,ts,jsx,tsx}", neutral: "#6b7280",
"neutral-content": "#d1d5db",
"base-100": "#ffffff",
"base-200": "#f3f4f6",
"base-content": "#0a0a0a",
info: "#a5f3fc",
success: "#22c55e",
warning: "#facc15",
error: "#dc2626",
},
},
// Red Dark Theme
{
"red-dark": {
primary: "#ef4444",
secondary: "#dc2626",
accent: "#6d28d9",
neutral: "#9ca3af",
"neutral-content": "#404040",
"base-100": "#171717",
"base-200": "#262626",
"base-content": "#fafafa",
info: "#009ee4",
success: "#00b17d",
warning: "#eac700",
error: "#f1293c",
},
},
// Green Light Theme
{
"green-light": {
primary: "#22c55e",
secondary: "#16a34a",
accent: "#6d28d9",
neutral: "#6b7280",
"neutral-content": "#d1d5db",
"base-100": "#ffffff",
"base-200": "#f3f4f6",
"base-content": "#0a0a0a",
info: "#a5f3fc",
success: "#22c55e",
warning: "#facc15",
error: "#dc2626",
},
},
// Green Dark Theme
{
"green-dark": {
primary: "#22c55e",
secondary: "#16a34a",
accent: "#6d28d9",
neutral: "#9ca3af",
"neutral-content": "#404040",
"base-100": "#171717",
"base-200": "#262626",
"base-content": "#fafafa",
info: "#009ee4",
success: "#00b17d",
warning: "#eac700",
error: "#f1293c",
},
},
// Orange Light Theme
{
"orange-light": {
primary: "#f97316",
secondary: "#ea580c",
accent: "#6d28d9",
neutral: "#9ca3af",
"neutral-content": "#404040",
"base-100": "#171717",
"base-200": "#262626",
"base-content": "#fafafa",
info: "#009ee4",
success: "#00b17d",
warning: "#eac700",
error: "#f1293c",
},
},
// Orange Dark Theme
{
"orange-dark": {
primary: "#f97316",
secondary: "#ea580c",
accent: "#6d28d9",
neutral: "#9ca3af",
"neutral-content": "#404040",
"base-100": "#171717",
"base-200": "#262626",
"base-content": "#fafafa",
info: "#009ee4",
success: "#00b17d",
warning: "#eac700",
error: "#f1293c",
},
},
],
},
darkMode: ["class", '[data-theme="dark"]'],
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
// For the "layouts" directory // For the "layouts" directory
"./layouts/**/*.{js,ts,jsx,tsx}", "./layouts/**/*.{js,ts,jsx,tsx}",
], ],
plugins: [ plugins: [
require("daisyui"), require("daisyui"),
plugin(({ addVariant }) => { plugin(({ addVariant }) => {
addVariant("dark", '&[data-theme="dark"]'); addVariant("dark", '&[data-theme="dark"]');
}), }),
], ],
}; };
-8
View File
@@ -134,11 +134,3 @@ export enum LinkType {
pdf, pdf,
image, image,
} }
export enum TokenExpiry {
sevenDays,
oneMonth,
twoMonths,
threeMonths,
never,
}
+2 -225
View File
@@ -621,13 +621,6 @@
dependencies: dependencies:
regenerator-runtime "^0.13.11" regenerator-runtime "^0.13.11"
"@babel/runtime@^7.13.10":
version "7.23.8"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650"
integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.21.0": "@babel/runtime@^7.21.0":
version "7.23.6" version "7.23.6"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d"
@@ -1275,148 +1268,6 @@
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.0.tgz#4ccf7f344eaeee08ca1e4a1bb2dc14e36ff1d5ec" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.0.tgz#4ccf7f344eaeee08ca1e4a1bb2dc14e36ff1d5ec"
integrity sha512-HqaFsnPmZOdMWkPq6tT2eTVTQyaAXEDdKszcZ4yc7DGMBIYRP6j/zAJTtZUG9SsMV8FaucdL5vRyxY/p5Ni28g== integrity sha512-HqaFsnPmZOdMWkPq6tT2eTVTQyaAXEDdKszcZ4yc7DGMBIYRP6j/zAJTtZUG9SsMV8FaucdL5vRyxY/p5Ni28g==
"@radix-ui/primitive@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd"
integrity sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989"
integrity sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-context@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c"
integrity sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-dialog@^1.0.4":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz#71657b1b116de6c7a0b03242d7d43e01062c7300"
integrity sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-focus-guards" "1.0.1"
"@radix-ui/react-focus-scope" "1.0.4"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-use-controllable-state" "1.0.1"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-dismissable-layer@1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown" "1.0.3"
"@radix-ui/react-focus-guards@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad"
integrity sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-focus-scope@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525"
integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-id@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0"
integrity sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-portal@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15"
integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-presence@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
integrity sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-primitive@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz#d49ea0f3f0b2fe3ab1cb5667eb03e8b843b914d0"
integrity sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-slot@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-use-callback-ref@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz#f4bb1f27f2023c984e6534317ebc411fc181107a"
integrity sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-controllable-state@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz#ecd2ced34e6330caf89a82854aa2f77e07440286"
integrity sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-escape-keydown@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz#217b840c250541609c66f67ed7bab2b733620755"
integrity sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-callback-ref" "1.0.1"
"@radix-ui/react-use-layout-effect@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz#be8c7bc809b0c8934acf6657b577daf948a75399"
integrity sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@rushstack/eslint-patch@^1.1.3": "@rushstack/eslint-patch@^1.1.3":
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728" resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728"
@@ -2193,13 +2044,6 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
aria-hidden@^1.1.1:
version "1.2.3"
resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.3.tgz#14aeb7fb692bbb72d69bebfa47279c1fd725e954"
integrity sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==
dependencies:
tslib "^2.0.0"
aria-query@^5.1.3: aria-query@^5.1.3:
version "5.1.3" version "5.1.3"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e"
@@ -2801,11 +2645,6 @@ detect-libc@^2.0.0:
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd"
integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==
dezalgo@^1.0.4: dezalgo@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81"
@@ -3457,11 +3296,6 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3:
has "^1.0.3" has "^1.0.3"
has-symbols "^1.0.3" has-symbols "^1.0.3"
get-nonce@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3"
integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==
get-pixels@^3.3.2: get-pixels@^3.3.2:
version "3.3.3" version "3.3.3"
resolved "https://registry.yarnpkg.com/get-pixels/-/get-pixels-3.3.3.tgz#71e2dfd4befb810b5478a61c6354800976ce01c7" resolved "https://registry.yarnpkg.com/get-pixels/-/get-pixels-3.3.3.tgz#71e2dfd4befb810b5478a61c6354800976ce01c7"
@@ -3835,13 +3669,6 @@ internal-slot@^1.0.3, internal-slot@^1.0.4:
has "^1.0.3" has "^1.0.3"
side-channel "^1.0.4" side-channel "^1.0.4"
invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
dependencies:
loose-envify "^1.0.0"
iota-array@^1.0.0: iota-array@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/iota-array/-/iota-array-1.0.0.tgz#81ef57fe5d05814cd58c2483632a99c30a0e8087" resolved "https://registry.yarnpkg.com/iota-array/-/iota-array-1.0.0.tgz#81ef57fe5d05814cd58c2483632a99c30a0e8087"
@@ -4256,7 +4083,7 @@ lodash@^4.17.21:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -5050,25 +4877,6 @@ react-is@^16.13.1, react-is@^16.7.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
react-remove-scroll-bar@^2.3.3:
version "2.3.4"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz#53e272d7a5cb8242990c7f144c44d8bd8ab5afd9"
integrity sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==
dependencies:
react-style-singleton "^2.2.1"
tslib "^2.0.0"
react-remove-scroll@2.5.5:
version "2.5.5"
resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz#1e31a1260df08887a8a0e46d09271b52b3a37e77"
integrity sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==
dependencies:
react-remove-scroll-bar "^2.3.3"
react-style-singleton "^2.2.1"
tslib "^2.1.0"
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-select@^5.7.4: react-select@^5.7.4:
version "5.7.4" version "5.7.4"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.4.tgz#d8cad96e7bc9d6c8e2709bdda8f4363c5dd7ea7d" resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.4.tgz#d8cad96e7bc9d6c8e2709bdda8f4363c5dd7ea7d"
@@ -5084,15 +4892,6 @@ react-select@^5.7.4:
react-transition-group "^4.3.0" react-transition-group "^4.3.0"
use-isomorphic-layout-effect "^1.1.2" use-isomorphic-layout-effect "^1.1.2"
react-style-singleton@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
integrity sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==
dependencies:
get-nonce "^1.0.0"
invariant "^2.2.4"
tslib "^2.0.0"
react-transition-group@^4.3.0: react-transition-group@^4.3.0:
version "4.4.5" version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"
@@ -5747,7 +5546,7 @@ tslib@^1.11.1, tslib@^1.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0, tslib@^2.1.0: tslib@^2.1.0:
version "2.6.2" version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
@@ -5860,26 +5659,11 @@ url-parse@^1.5.3:
querystringify "^2.1.1" querystringify "^2.1.1"
requires-port "^1.0.0" requires-port "^1.0.0"
use-callback-ref@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.1.tgz#9be64c3902cbd72b07fe55e56408ae3a26036fd0"
integrity sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==
dependencies:
tslib "^2.0.0"
use-isomorphic-layout-effect@^1.1.2: use-isomorphic-layout-effect@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-sidecar@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.2.tgz#2f43126ba2d7d7e117aa5855e5d8f0276dfe73c2"
integrity sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==
dependencies:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@1.2.0: use-sync-external-store@1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
@@ -5912,13 +5696,6 @@ v8-compile-cache-lib@^3.0.1:
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
vaul@^0.8.8:
version "0.8.8"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-0.8.8.tgz#c5edc041825fdeaddf0a89e326abcc7ac7449a2d"
integrity sha512-Z9K2b90M/LtY/sRyM1yfA8Y4mHC/5WIqhO2u7Byr49r5LQXkLGdVXiehsnjtws9CL+DyknwTuRMJXlCOHTqg/g==
dependencies:
"@radix-ui/react-dialog" "^1.0.4"
verror@1.10.0: verror@1.10.0:
version "1.10.0" version "1.10.0"
resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"