Merge branch 'feat/extra-login-providers' into main

This commit is contained in:
Daniel 2023-12-07 09:14:25 +03:30 committed by GitHub
commit 8ba2cecf06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 4416 additions and 2124 deletions

View File

@ -14,6 +14,7 @@ AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION= NEXT_PUBLIC_DISABLE_REGISTRATION=
NEXT_PUBLIC_DISABLE_LOGIN= NEXT_PUBLIC_DISABLE_LOGIN=
RE_ARCHIVE_LIMIT= RE_ARCHIVE_LIMIT=
NEXT_PUBLIC_MAX_UPLOAD_SIZE=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=

1
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -9,7 +9,7 @@ type Props = {
export default function AnnouncementBar({ toggleAnnouncementBar }: Props) { export default function AnnouncementBar({ toggleAnnouncementBar }: Props) {
return ( return (
<div className="fixed w-full z-20 dark:bg-neutral-900 bg-white"> <div className="fixed w-full z-20 bg-base-200">
<div className="w-full h-10 rainbow flex items-center justify-center"> <div className="w-full h-10 rainbow flex items-center justify-center">
<div className="w-fit font-semibold"> <div className="w-fit font-semibold">
🎉{" "} 🎉{" "}

View File

@ -12,23 +12,17 @@ type Props = {
export default function Checkbox({ label, state, className, onClick }: Props) { export default function Checkbox({ label, state, className, onClick }: Props) {
return ( return (
<label <label
className={`cursor-pointer flex items-center gap-2 ${className || ""}`} className={`label cursor-pointer flex gap-2 justify-start ${
className || ""
}`}
> >
<input <input
type="checkbox" type="checkbox"
checked={state} checked={state}
onChange={onClick} onChange={onClick}
className="peer sr-only" className="checkbox checkbox-primary"
/> />
<FontAwesomeIcon <span className="label-text">{label}</span>
icon={faSquareCheck}
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:block hidden"
/>
<FontAwesomeIcon
icon={faSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:hidden block"
/>
<span className="rounded select-none">{label}</span>
</label> </label>
); );
} }

View File

@ -2,30 +2,25 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsis, faGlobe, faLink } from "@fortawesome/free-solid-svg-icons"; import { faEllipsis, faGlobe, faLink } from "@fortawesome/free-solid-svg-icons";
import Link from "next/link"; import Link from "next/link";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Dropdown from "./Dropdown"; import { useEffect, useState } from "react";
import { useState } from "react";
import ProfilePhoto from "./ProfilePhoto"; import ProfilePhoto from "./ProfilePhoto";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons"; import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import useModalStore from "@/store/modals";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { useTheme } from "next-themes"; import useLocalSettingsStore from "@/store/localSettings";
import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import EditCollectionModal from "./ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "./ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "./ModalContent/DeleteCollectionModal";
type Props = { type Props = {
collection: CollectionIncludingMembersAndLinkCount; collection: CollectionIncludingMembersAndLinkCount;
className?: string; className?: string;
}; };
type DropdownTrigger =
| {
x: number;
y: number;
}
| false;
export default function CollectionCard({ collection, className }: Props) { export default function CollectionCard({ collection, className }: Props) {
const { setModal } = useModalStore(); const { settings } = useLocalSettingsStore();
const { account } = useAccountStore();
const { theme } = useTheme();
const formattedDate = new Date(collection.createdAt as string).toLocaleString( const formattedDate = new Date(collection.createdAt as string).toLocaleString(
"en-US", "en-US",
@ -36,144 +31,183 @@ export default function CollectionCard({ collection, className }: Props) {
} }
); );
const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
const permissions = usePermissions(collection.id as number); const permissions = usePermissions(collection.id as number);
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
});
useEffect(() => {
const fetchOwner = async () => {
if (collection && collection.ownerId !== account.id) {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
} else if (collection && collection.ownerId === account.id) {
setCollectionOwner({
id: account.id as number,
name: account.name,
username: account.username as string,
image: account.image as string,
});
}
};
fetchOwner();
}, [collection]);
const [editCollectionModal, setEditCollectionModal] = useState(false);
const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false);
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
return ( return (
<> <div className="relative">
<div className="dropdown dropdown-bottom dropdown-end absolute top-3 right-3 z-20">
<div
tabIndex={0}
role="button"
className="btn btn-ghost btn-sm btn-square text-neutral"
>
<FontAwesomeIcon icon={faEllipsis} title="More" className="w-5 h-5" />
</div>
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
{permissions === true ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionModal(true);
}}
>
Edit Collection Info
</div>
</li>
) : undefined}
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditCollectionSharingModal(true);
}}
>
{permissions === true ? "Share and Collaborate" : "View Team"}
</div>
</li>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setDeleteCollectionModal(true);
}}
>
{permissions === true ? "Delete Collection" : "Leave Collection"}
</div>
</li>
</ul>
</div>
<div <div
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)}
>
{collectionOwner.id ? (
<ProfilePhoto
src={collectionOwner.image || undefined}
name={collectionOwner.name}
/>
) : undefined}
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
name={e.user.name}
className="-ml-3"
/>
);
})
.slice(0, 3)}
{collection.members.length - 3 > 0 ? (
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span>
</div>
</div>
) : null}
</div>
<Link
href={`/collections/${collection.id}`}
style={{ style={{
backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${ backgroundImage: `linear-gradient(45deg, ${collection.color}30 10%, ${
theme === "dark" ? "#262626" : "#f3f4f6" settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 50%, ${theme === "dark" ? "#262626" : "#f9fafb"} 100%)`, } 50%, ${
settings.theme === "dark" ? "oklch(var(--b2))" : "oklch(var(--b2))"
} 100%)`,
}} }}
className={`border border-solid border-sky-100 dark:border-neutral-700 self-stretch min-h-[12rem] rounded-2xl shadow duration-100 hover:shadow-none hover:opacity-80 group relative ${ className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content"
className || ""
}`}
> >
<div <div className="card-body flex flex-col justify-between min-h-[12rem]">
onClick={(e) => setExpandDropdown({ x: e.clientX, y: e.clientY })} <div className="flex justify-between">
id={"expand-dropdown" + collection.id} <p className="card-title break-words line-clamp-2 w-full">
className="inline-flex absolute top-5 right-5 rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" {collection.name}
> </p>
<FontAwesomeIcon <div className="w-8 h-8 ml-10"></div>
icon={faEllipsis} </div>
id={"expand-dropdown" + collection.id}
className="w-5 h-5 text-gray-500 dark:text-gray-300" <div className="flex justify-end items-center">
/> <div className="text-right">
</div> <div className="font-bold text-sm flex justify-end gap-1 items-center">
<Link
href={`/collections/${collection.id}`}
className="flex flex-col gap-2 justify-between min-h-[12rem] h-full select-none p-5"
>
<p className="text-2xl capitalize text-black dark:text-white break-words line-clamp-3 w-4/5">
{collection.name}
</p>
<div className="flex justify-between items-center">
<div className="flex items-center w-full">
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<ProfilePhoto
key={i}
src={e.user.image ? e.user.image : undefined}
className="-mr-3 border-[3px]"
/>
);
})
.slice(0, 4)}
{collection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700 -mr-3">
+{collection.members.length - 4}
</div>
) : null}
</div>
<div className="text-right w-40">
<div className="text-black dark:text-white font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic ? ( {collection.isPublic ? (
<FontAwesomeIcon <FontAwesomeIcon
icon={faGlobe} icon={faGlobe}
title="This collection is being shared publicly." title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300" className="w-4 h-4 drop-shadow text-neutral"
/> />
) : undefined} ) : undefined}
<FontAwesomeIcon <FontAwesomeIcon
icon={faLink} icon={faLink}
className="w-5 h-5 text-gray-500 dark:text-gray-300" className="w-5 h-5 text-neutral"
/> />
{collection._count && collection._count.links} {collection._count && collection._count.links}
</div> </div>
<div className="flex items-center justify-end gap-1 text-gray-500 dark:text-gray-300"> <div className="flex items-center justify-end gap-1 text-neutral">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" /> <p className="font-bold text-xs flex gap-1 items-center">
<p className="font-bold text-xs">{formattedDate}</p> <FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />{" "}
{formattedDate}
</p>
</div> </div>
</div> </div>
</div> </div>
</Link> </div>
</div> </Link>
{expandDropdown ? ( {editCollectionModal ? (
<Dropdown <EditCollectionModal
points={{ x: expandDropdown.x, y: expandDropdown.y }} onClose={() => setEditCollectionModal(false)}
items={[ activeCollection={collection}
permissions === true
? {
name: "Edit Collection Info",
onClick: () => {
collection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
});
setExpandDropdown(false);
},
}
: undefined,
{
name: permissions === true ? "Share/Collaborate" : "View Team",
onClick: () => {
collection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
defaultIndex: permissions === true ? 1 : 0,
});
setExpandDropdown(false);
},
},
{
name:
permissions === true ? "Delete Collection" : "Leave Collection",
onClick: () => {
collection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: collection,
defaultIndex: permissions === true ? 2 : 1,
});
setExpandDropdown(false);
},
},
]}
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "expand-dropdown" + collection.id)
setExpandDropdown(false);
}}
className="w-fit"
/> />
) : null} ) : undefined}
</> {editCollectionSharingModal ? (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
) : undefined}
{deleteCollectionModal ? (
<DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)}
activeCollection={collection}
/>
) : undefined}
</div>
); );
} }

View File

@ -10,19 +10,12 @@ type Props = {
export default function dashboardItem({ name, value, icon }: Props) { export default function dashboardItem({ name, value, icon }: Props) {
return ( return (
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<div className="p-4 bg-sky-500 bg-opacity-20 dark:bg-opacity-10 rounded-xl select-none"> <div className="p-4 bg-primary/20 rounded-xl select-none">
<FontAwesomeIcon <FontAwesomeIcon icon={icon} className="w-8 h-8 text-primary" />
icon={icon}
className="w-8 h-8 text-sky-500 dark:text-sky-500"
/>
</div> </div>
<div className="flex flex-col justify-center"> <div className="flex flex-col justify-center">
<p className="text-gray-500 dark:text-gray-400 text-sm tracking-wider"> <p className="text-neutral text-sm tracking-wider">{name}</p>
{name} <p className="font-thin text-6xl text-primary mt-2">{value}</p>
</p>
<p className="font-thin text-6xl text-sky-500 dark:text-sky-500 mt-2">
{value}
</p>
</div> </div>
</div> </div>
); );

View File

@ -78,13 +78,13 @@ export default function Dropdown({
onClickOutside={onClickOutside} onClickOutside={onClickOutside}
className={`${ className={`${
className || "" className || ""
} py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`} } py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
> >
{items.map((e, i) => { {items.map((e, i) => {
const inner = e && ( const inner = e && (
<div className="cursor-pointer rounded-md"> <div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 dark:hover:bg-neutral-700 duration-100"> <div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="text-black dark:text-white select-none">{e.name}</p> <p className="select-none">{e.name}</p>
</div> </div>
</div> </div>
); );

View File

@ -1,9 +1,8 @@
import React, { SetStateAction } from "react"; import React from "react";
import ClickAwayHandler from "./ClickAwayHandler"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Checkbox from "./Checkbox"; import { faFilter } from "@fortawesome/free-solid-svg-icons";
type Props = { type Props = {
setFilterDropdown: (value: SetStateAction<boolean>) => void;
setSearchFilter: Function; setSearchFilter: Function;
searchFilter: { searchFilter: {
name: boolean; name: boolean;
@ -15,64 +14,123 @@ type Props = {
}; };
export default function FilterSearchDropdown({ export default function FilterSearchDropdown({
setFilterDropdown,
setSearchFilter, setSearchFilter,
searchFilter, searchFilter,
}: Props) { }: Props) {
return ( return (
<ClickAwayHandler <div className="dropdown dropdown-bottom dropdown-end">
onClickOutside={(e: Event) => { <div
const target = e.target as HTMLInputElement; tabIndex={0}
if (target.id !== "filter-dropdown") setFilterDropdown(false); role="button"
}} className="btn btn-sm btn-square btn-ghost"
className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-40" >
> <FontAwesomeIcon
<p className="mb-2 text-black dark:text-white text-center font-semibold"> icon={faFilter}
Filter by id="sort-dropdown"
</p> className="w-5 h-5 text-neutral"
<div className="flex flex-col gap-2">
<Checkbox
label="Name"
state={searchFilter.name}
onClick={() =>
setSearchFilter({ ...searchFilter, name: !searchFilter.name })
}
/>
<Checkbox
label="Link"
state={searchFilter.url}
onClick={() =>
setSearchFilter({ ...searchFilter, url: !searchFilter.url })
}
/>
<Checkbox
label="Description"
state={searchFilter.description}
onClick={() =>
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
})
}
/>
<Checkbox
label="Full Content"
state={searchFilter.textContent}
onClick={() =>
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
})
}
/>
<Checkbox
label="Tags"
state={searchFilter.tags}
onClick={() =>
setSearchFilter({ ...searchFilter, tags: !searchFilter.tags })
}
/> />
</div> </div>
</ClickAwayHandler> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary [--chkfg:white]"
checked={searchFilter.name}
onChange={() => {
setSearchFilter({ ...searchFilter, name: !searchFilter.name });
}}
/>
<span className="label-text">Name</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary [--chkfg:white]"
checked={searchFilter.url}
onChange={() => {
setSearchFilter({ ...searchFilter, url: !searchFilter.url });
}}
/>
<span className="label-text">Link</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary [--chkfg:white]"
checked={searchFilter.description}
onChange={() => {
setSearchFilter({
...searchFilter,
description: !searchFilter.description,
});
}}
/>
<span className="label-text">Description</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary [--chkfg:white]"
checked={searchFilter.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary [--chkfg:white]"
checked={searchFilter.tags}
onChange={() => {
setSearchFilter({
...searchFilter,
tags: !searchFilter.tags,
});
}}
/>
<span className="label-text">Tags</span>
</label>
</li>
</ul>
</div>
); );
} }

View File

@ -1,9 +1,9 @@
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Select from "react-select";
import { styles } from "./styles"; import { styles } from "./styles";
import { Options } from "./types"; import { Options } from "./types";
import CreatableSelect from "react-select/creatable";
type Props = { type Props = {
onChange: any; onChange: any;
@ -43,8 +43,8 @@ export default function CollectionSelection({ onChange, defaultValue }: Props) {
}, [collections]); }, [collections]);
return ( return (
<Select <CreatableSelect
isClearable isClearable={false}
className="react-select-container" className="react-select-container"
classNamePrefix="react-select" classNamePrefix="react-select"
onChange={onChange} onChange={onChange}

View File

@ -27,7 +27,7 @@ export default function TagSelection({ onChange, defaultValue }: Props) {
return ( return (
<CreatableSelect <CreatableSelect
isClearable isClearable={false}
className="react-select-container" className="react-select-container"
classNamePrefix="react-select" classNamePrefix="react-select"
onChange={onChange} onChange={onChange}

View File

@ -8,20 +8,27 @@ export const styles: StylesConfig = {
...styles, ...styles,
fontFamily: font, fontFamily: font,
cursor: "pointer", cursor: "pointer",
backgroundColor: state.isSelected ? "#0ea5e9" : "inherit", backgroundColor: state.isSelected ? "oklch(var(--p))" : "inherit",
"&:hover": { "&:hover": {
backgroundColor: state.isSelected ? "#0ea5e9" : "#e2e8f0", backgroundColor: state.isSelected
? "oklch(var(--p))"
: "oklch(var(--nc))",
}, },
transition: "all 50ms", transition: "all 50ms",
}), }),
control: (styles) => ({ control: (styles, state) => ({
...styles, ...styles,
fontFamily: font, fontFamily: font,
border: "none", borderRadius: "0.375rem",
border: state.isFocused
? "1px solid oklch(var(--p))"
: "1px solid oklch(var(--nc))",
boxShadow: "none",
minHeight: "2.6rem",
}), }),
container: (styles) => ({ container: (styles, state) => ({
...styles, ...styles,
border: "1px solid #e0f2fe", height: "full",
borderRadius: "0.375rem", borderRadius: "0.375rem",
lineHeight: "1.25rem", lineHeight: "1.25rem",
// "@media screen and (min-width: 1024px)": { // "@media screen and (min-width: 1024px)": {
@ -58,4 +65,5 @@ export const styles: StylesConfig = {
backgroundColor: "#38bdf8", backgroundColor: "#38bdf8",
}, },
}), }),
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}; };

View File

@ -10,18 +10,24 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Dropdown from "./Dropdown";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import useModalStore from "@/store/modals"; import {
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons"; faCalendarDays,
faFileImage,
faFilePdf,
} from "@fortawesome/free-regular-svg-icons";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import Link from "next/link"; import Link from "next/link";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import EditLinkModal from "./ModalContent/EditLinkModal";
import DeleteLinkModal from "./ModalContent/DeleteLinkModal";
import ExpandedLink from "./ModalContent/ExpandedLink";
import PreservedFormatsModal from "./ModalContent/PreservedFormatsModal";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -29,22 +35,11 @@ type Props = {
className?: string; className?: string;
}; };
type DropdownTrigger =
| {
x: number;
y: number;
}
| false;
export default function LinkCard({ link, count, className }: Props) { export default function LinkCard({ link, count, className }: Props) {
const { setModal } = useModalStore();
const router = useRouter(); const router = useRouter();
const permissions = usePermissions(link.collection.id as number); const permissions = usePermissions(link.collection.id as number);
const [expandDropdown, setExpandDropdown] = useState<DropdownTrigger>(false);
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const { links } = useLinkStore(); const { links } = useLinkStore();
@ -54,7 +49,7 @@ export default function LinkCard({ link, count, className }: Props) {
let shortendURL; let shortendURL;
try { try {
shortendURL = new URL(link.url).host.toLowerCase(); shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@ -74,15 +69,13 @@ export default function LinkCard({ link, count, className }: Props) {
); );
}, [collections, links]); }, [collections, links]);
const { removeLink, updateLink, getLink } = useLinkStore(); const { removeLink, updateLink } = useLinkStore();
const pinLink = async () => { const pinLink = async () => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0]; const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0];
const load = toast.loading("Applying..."); const load = toast.loading("Applying...");
setExpandDropdown(false);
const response = await updateLink({ const response = await updateLink({
...link, ...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }], pinnedBy: isAlreadyPinned ? undefined : [{ id: account.id }],
@ -94,25 +87,6 @@ export default function LinkCard({ link, count, className }: Props) {
toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`); toast.success(`Link ${isAlreadyPinned ? "Unpinned!" : "Pinned!"}`);
}; };
const updateArchive = async () => {
const load = toast.loading("Sending request...");
setExpandDropdown(false);
const response = await fetch(`/api/v1/links/${link.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(`Link is being archived...`);
getLink(link.id as number);
} else toast.error(data.response);
};
const deleteLink = async () => { const deleteLink = async () => {
const load = toast.loading("Deleting..."); const load = toast.loading("Deleting...");
@ -121,10 +95,10 @@ export default function LinkCard({ link, count, className }: Props) {
toast.dismiss(load); toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`); response.ok && toast.success(`Link Deleted.`);
setExpandDropdown(false);
}; };
const url = isValidUrl(link.url) ? new URL(link.url) : undefined; const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
const formattedDate = new Date(link.createdAt as string).toLocaleString( const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US", "en-US",
@ -135,167 +109,214 @@ export default function LinkCard({ link, count, className }: Props) {
} }
); );
const [editLinkModal, setEditLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const [expandedLink, setExpandedLink] = useState(false);
return ( return (
<> <div
<div className={`h-fit border border-solid border-neutral-content bg-base-200 shadow-md hover:shadow-none duration-100 rounded-2xl relative ${
className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${ className || ""
className || "" }`}
}`} >
> {permissions === true ||
{(permissions === true || permissions?.canUpdate ||
permissions?.canUpdate || permissions?.canDelete ? (
permissions?.canDelete) && ( <div className="dropdown dropdown-left absolute top-3 right-3 z-20">
<div <div
onClick={(e) => { tabIndex={0}
setExpandDropdown({ x: e.clientX, y: e.clientY }); role="button"
}} className="btn btn-ghost btn-sm btn-square text-neutral"
id={"expand-dropdown" + link.id}
className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-4 top-4 z-10 duration-100 p-1"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faEllipsis} icon={faEllipsis}
title="More" title="More"
className="w-5 h-5" className="w-5 h-5"
id={"expand-dropdown" + link.id} id={"expand-dropdown" + collection.id}
/> />
</div> </div>
<ul className="dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mr-1">
{permissions === true ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
pinLink();
}}
>
{link?.pinnedBy && link.pinnedBy[0]
? "Unpin"
: "Pin to Dashboard"}
</div>
</li>
) : undefined}
{permissions === true || permissions?.canUpdate ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setEditLinkModal(true);
}}
>
Edit
</div>
</li>
) : undefined}
{permissions === true ? (
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setPreservedFormatsModal(true);
// updateArchive();
}}
>
Preserved Formats
</div>
</li>
) : undefined}
{permissions === true || permissions?.canDelete ? (
<li>
<div
role="button"
tabIndex={0}
onClick={(e) => {
(document?.activeElement as HTMLElement)?.blur();
e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
}}
>
Delete
</div>
</li>
) : undefined}
</ul>
</div>
) : undefined}
<Link
href={"/links/" + link.id}
// onClick={
// () => router.push("/links/" + link.id)
// // setExpandedLink(true)
// }
className="flex flex-col justify-between cursor-pointer h-full w-full gap-1 p-3"
>
{link.url && url ? (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64}
height={64}
alt=""
className={`absolute w-12 bg-white shadow rounded-md p-1 bottom-3 right-3 select-none z-10`}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
) : link.type === "pdf" ? (
<FontAwesomeIcon
icon={faFilePdf}
className="absolute h-12 w-12 bg-primary/20 text-primary shadow rounded-md p-2 bottom-3 right-3 select-none z-10"
/>
) : link.type === "image" ? (
<FontAwesomeIcon
icon={faFileImage}
className="absolute h-12 w-12 bg-primary/20 text-primary shadow rounded-md p-2 bottom-3 right-3 select-none z-10"
/>
) : undefined}
<div className="flex items-baseline gap-1">
<p className="text-sm text-neutral">{count + 1}</p>
<p className="text-lg truncate w-full pr-8">
{unescapeString(link.name || link.description) || shortendURL}
</p>
</div>
{link.url ? (
<div
onClick={(e) => {
e.preventDefault();
window.open(link.url || "", "_blank");
}}
className="flex items-center gap-1 max-w-full w-fit text-neutral hover:opacity-60 duration-100"
>
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</div>
) : (
<div className="badge badge-primary badge-sm my-1">{link.type}</div>
)} )}
<div <div
onClick={() => router.push("/links/" + link.id)} onClick={(e) => {
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-4" e.preventDefault();
> router.push(`/collections/${link.collection.id}`);
{url && account.displayLinkIcons && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={64}
height={64}
alt=""
className={`${
account.blurredFavicons ? "blur-sm " : ""
}absolute w-16 group-hover:opacity-80 duration-100 rounded-2xl bottom-5 right-5 opacity-60 select-none z-10`}
draggable="false"
onError={(e) => {
const target = e.target as HTMLElement;
target.style.display = "none";
}}
/>
)}
<div className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1">
<p className="text-sm text-gray-500 dark:text-gray-300">
{count + 1}
</p>
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8">
{unescapeString(link.name || link.description)}
</p>
</div>
<Link
href={`/collections/${link.collection.id}`}
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit my-1 hover:opacity-70 duration-100"
>
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }}
/>
<p className="text-black dark:text-white truncate capitalize w-full">
{collection?.name}
</p>
</Link>
{/* {link.tags[0] ? (
<div className="flex gap-3 items-center flex-wrap my-2 truncate relative">
<div className="flex gap-1 items-center flex-nowrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="px-2 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</Link>
))}
</div>
<div className="absolute w-1/2 top-0 bottom-0 right-0 bg-gradient-to-r from-transparent to-slate-100 dark:to-neutral-800 to-35%"></div>
</div>
) : undefined} */}
<Link
href={link.url}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100"
>
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p>
</Link>
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
</div>
</div>
</div>
{expandDropdown ? (
<Dropdown
points={{ x: expandDropdown.x, y: expandDropdown.y }}
items={[
permissions === true
? {
name:
link?.pinnedBy && link.pinnedBy[0]
? "Unpin"
: "Pin to Dashboard",
onClick: pinLink,
}
: undefined,
permissions === true || permissions?.canUpdate
? {
name: "Edit",
onClick: () => {
setModal({
modal: "LINK",
state: true,
method: "UPDATE",
active: link,
});
setExpandDropdown(false);
},
}
: undefined,
permissions === true
? {
name: "Refresh Link",
onClick: updateArchive,
}
: undefined,
permissions === true || permissions?.canDelete
? {
name: "Delete",
onClick: deleteLink,
}
: undefined,
]}
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "expand-dropdown" + link.id)
setExpandDropdown(false);
}} }}
className="w-40" className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
>
<FontAwesomeIcon
icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow"
style={{ color: collection?.color }}
/>
<p className="truncate capitalize w-full">{collection?.name}</p>
</div>
<div className="flex items-center gap-1 text-neutral">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
{/* {link.tags[0] ? (
<div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-nowrap">
{link.tags.map((e, i) => (
<Link
href={"/tags/" + e.id}
key={i}
onClick={(e) => {
e.stopPropagation();
}}
className="btn btn-xs btn-ghost truncate max-w-[19rem]"
>
#{e.name}
</Link>
))}
</div>
<div className="absolute w-1/2 top-0 bottom-0 right-0 bg-gradient-to-r from-transparent to-base-200 to-35%"></div>
</div>
) : (
<p className="text-xs mt-2 p-1 font-semibold italic">No Tags</p>
)} */}
</Link>
{editLinkModal ? (
<EditLinkModal
onClose={() => setEditLinkModal(false)}
activeLink={link}
/> />
) : null} ) : undefined}
</> {deleteLinkModal ? (
<DeleteLinkModal
onClose={() => setDeleteLinkModal(false)}
activeLink={link}
/>
) : undefined}
{preservedFormatsModal ? (
<PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)}
activeLink={link}
/>
) : undefined}
{/* {expandedLink ? (
<ExpandedLink onClose={() => setExpandedLink(false)} link={link} />
) : undefined} */}
</div>
); );
} }

View File

@ -2,7 +2,7 @@ import { faFolder, faLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image"; import Image from "next/image";
import { faCalendarDays } from "@fortawesome/free-regular-svg-icons"; import { faCalendarDays } from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import A from "next/link"; import A from "next/link";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { Link } from "@prisma/client"; import { Link } from "@prisma/client";
@ -49,7 +49,7 @@ export default function LinkPreview({ link, className, settings }: Props) {
return ( return (
<> <>
<div <div
className={`h-fit border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-2xl relative group ${ className={`h-fit border border-solid border-neutral-content bg-base-200 shadow hover:shadow-none duration-100 rounded-2xl relative group ${
className || "" className || ""
}`} }`}
> >
@ -74,19 +74,17 @@ export default function LinkPreview({ link, className, settings }: Props) {
<div className="flex justify-between gap-5 w-full h-full z-0"> <div className="flex justify-between gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full"> <div className="flex flex-col justify-between w-full">
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<p className="text-sm text-gray-500 dark:text-gray-300">{1}</p> <p className="text-sm text-neutral">{1}</p>
<p className="text-lg text-black dark:text-white truncate capitalize w-full pr-8"> <p className="text-lg truncate capitalize w-full pr-8">
{unescapeString(link.name as string)} {unescapeString(link.name as string)}
</p> </p>
</div> </div>
<div className="flex items-center gap-1 max-w-full w-fit my-1 hover:opacity-70 duration-100"> <div className="flex items-center gap-1 max-w-full w-fit my-1 hover:opacity-70 duration-100">
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
className="w-4 h-4 mt-1 drop-shadow text-sky-400" className="w-4 h-4 mt-1 drop-shadow text-primary"
/> />
<p className="text-black dark:text-white truncate capitalize w-full"> <p className="truncate capitalize w-full">Landing Pages </p>
Landing Pages
</p>
</div> </div>
<A <A
href={link.url as string} href={link.url as string}
@ -94,12 +92,12 @@ export default function LinkPreview({ link, className, settings }: Props) {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
className="flex items-center gap-1 max-w-full w-fit text-gray-500 dark:text-gray-300 hover:opacity-70 duration-100" className="flex items-center gap-1 max-w-full w-fit text-neutral hover:opacity-70 duration-100"
> >
<FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" /> <FontAwesomeIcon icon={faLink} className="mt-1 w-4 h-4" />
<p className="truncate w-full">{shortendURL}</p> <p className="truncate w-full">{shortendURL}</p>
</A> </A>
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300"> <div className="flex items-center gap-1 text-neutral">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" /> <FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p> <p>{formattedDate}</p>
</div> </div>

View File

@ -47,7 +47,7 @@ export default function LinkSidebar({ className, onClick }: Props) {
return ( return (
<div <div
className={`dark:bg-neutral-900 bg-white h-full lg:w-10 w-62 overflow-y-auto lg:p-0 p-5 border-solid border-white border dark:border-neutral-900 dark:lg:border-r-neutral-900 lg:border-r-white border-r-sky-100 dark:border-r-neutral-700 z-20 flex flex-col gap-5 lg:justify-center justify-start ${ className={`bg-base-100 h-full w-64 overflow-y-auto border-solid border border-base-100 border-r-neutral-content p-5 z-20 flex flex-col gap-5 justify-between ${
className || "" className || ""
}`} }`}
> >
@ -71,14 +71,9 @@ export default function LinkSidebar({ className, onClick }: Props) {
}} }}
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon icon={faPen} className="w-6 h-6 text-neutral" />
icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300"
/>
<p className="text-black dark:text-white truncate w-full lg:hidden"> <p className="truncate w-full lg:hidden">Edit</p>
Edit
</p>
</div> </div>
) : undefined} ) : undefined}
@ -99,12 +94,10 @@ export default function LinkSidebar({ className, onClick }: Props) {
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faBoxesStacked} icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300" className="w-6 h-6 text-neutral"
/> />
<p className="text-black dark:text-white truncate w-full lg:hidden"> <p className="truncate w-full lg:hidden">Preserved Formats</p>
Preserved Formats
</p>
</div> </div>
{link?.collection.ownerId === userId || {link?.collection.ownerId === userId ||
@ -124,12 +117,10 @@ export default function LinkSidebar({ className, onClick }: Props) {
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faTrashCan} icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300" className="w-6 h-6 text-neutral"
/> />
<p className="text-black dark:text-white truncate w-full lg:hidden"> <p className="truncate w-full lg:hidden">Delete</p>
Delete
</p>
</div> </div>
) : undefined} ) : undefined}
</div> </div>

33
components/Modal.tsx Normal file
View File

@ -0,0 +1,33 @@
import { MouseEventHandler, ReactNode, useEffect } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClose } from "@fortawesome/free-solid-svg-icons";
type Props = {
toggleModal: Function;
children: ReactNode;
className?: string;
};
export default function Modal({ toggleModal, className, children }: Props) {
return (
<div className="overflow-y-auto 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-30">
<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">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-3 right-3 btn btn-sm outline-none btn-circle btn-ghost"
>
<FontAwesomeIcon icon={faClose} className="w-4 h-4 text-neutral" />
</div>
{children}
</div>
</ClickAwayHandler>
</div>
);
}

View File

@ -60,7 +60,7 @@ export default function CollectionInfo({
<div className="flex flex-col gap-3 sm:w-[35rem] w-80"> <div className="flex flex-col gap-3 sm:w-[35rem] w-80">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<div className="w-full"> <div className="w-full">
<p className="text-black dark:text-white mb-2">Name</p> <p className="mb-2">Name</p>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<TextInput <TextInput
value={collection.name} value={collection.name}
@ -71,7 +71,7 @@ export default function CollectionInfo({
/> />
<div className="color-picker flex justify-between"> <div className="color-picker flex justify-between">
<div className="flex flex-col justify-between items-center w-32"> <div className="flex flex-col justify-between items-center w-32">
<p className="w-full text-black dark:text-white mb-2">Color</p> <p className="w-full mb-2">Color</p>
<div style={{ color: collection.color }}> <div style={{ color: collection.color }}>
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
@ -79,7 +79,7 @@ export default function CollectionInfo({
/> />
</div> </div>
<div <div
className="py-1 px-2 rounded-md text-xs font-semibold cursor-pointer text-black dark:text-white hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100" className="btn btn-ghost btn-xs"
onClick={() => onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" }) setCollection({ ...collection, color: "#0ea5e9" })
} }
@ -96,9 +96,9 @@ export default function CollectionInfo({
</div> </div>
<div className="w-full"> <div className="w-full">
<p className="text-black dark:text-white mb-2">Description</p> <p className="mb-2">Description</p>
<textarea <textarea
className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-gray-50 dark:bg-neutral-950 p-2 outline-none border-sky-100 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600" className="w-full h-[11.4rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-sky-300 dark:focus:border-sky-600"
placeholder="The purpose of this Collection..." placeholder="The purpose of this Collection..."
value={collection.description} value={collection.description}
onChange={(e) => onChange={(e) =>

View File

@ -51,7 +51,7 @@ export default function DeleteCollection({
<p className="text-red-500 font-bold text-center">Warning!</p> <p className="text-red-500 font-bold text-center">Warning!</p>
<div className="max-h-[20rem] overflow-y-auto"> <div className="max-h-[20rem] overflow-y-auto">
<div className="text-black dark:text-white"> <div>
<p> <p>
Please note that deleting the collection will permanently remove Please note that deleting the collection will permanently remove
all its contents, including the following: all its contents, including the following:
@ -82,7 +82,7 @@ export default function DeleteCollection({
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-black dark:text-white text-center"> <p className="text-center">
To confirm, type &quot; To confirm, type &quot;
<span className="font-bold">{collection.name}</span> <span className="font-bold">{collection.name}</span>
&quot; in the box below: &quot; in the box below:
@ -98,9 +98,7 @@ export default function DeleteCollection({
</div> </div>
</> </>
) : ( ) : (
<p className="text-black dark:text-white"> <p>Click the button below to leave the current collection.</p>
Click the button below to leave the current collection.
</p>
)} )}
<div <div

View File

@ -102,7 +102,7 @@ export default function TeamManagement({
<div className="flex flex-col gap-3 sm:w-[35rem] w-80"> <div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{permissions === true && ( {permissions === true && (
<> <>
<p className="text-black dark:text-white">Make Public</p> <p>Make Public</p>
<Checkbox <Checkbox
label="Make this a public collection." label="Make this a public collection."
@ -112,7 +112,7 @@ export default function TeamManagement({
} }
/> />
<p className="text-gray-500 dark:text-gray-300 text-sm"> <p className="text-neutral text-sm">
This will let <b>Anyone</b> to view this collection. This will let <b>Anyone</b> to view this collection.
</p> </p>
</> </>
@ -120,9 +120,7 @@ export default function TeamManagement({
{collection.isPublic ? ( {collection.isPublic ? (
<div> <div>
<p className="text-black dark:text-white mb-2"> <p className="mb-2">Public Link (Click to copy)</p>
Public Link (Click to copy)
</p>
<div <div
onClick={() => { onClick={() => {
try { try {
@ -133,7 +131,7 @@ export default function TeamManagement({
console.log(err); console.log(err);
} }
}} }}
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 dark:bg-neutral-950 border-sky-100 dark:border-neutral-700 border-solid border outline-none hover:border-sky-300 dark:hover:border-sky-600 duration-100 cursor-text" className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 dark:bg-neutral-950 border-neutral-content border-solid border outline-none hover:border-sky-300 dark:hover:border-sky-600 duration-100 cursor-text"
> >
{publicCollectionURL} {publicCollectionURL}
</div> </div>
@ -141,12 +139,12 @@ export default function TeamManagement({
) : null} ) : null}
{permissions !== true && collection.isPublic && ( {permissions !== true && collection.isPublic && (
<hr className="mb-3 border border-sky-100 dark:border-neutral-700" /> <div className="divider mb-3 mt-0"></div>
)} )}
{permissions === true && ( {permissions === true && (
<> <>
<p className="text-black dark:text-white">Member Management</p> <p>Member Management</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TextInput <TextInput
@ -183,7 +181,7 @@ export default function TeamManagement({
{collection?.members[0]?.user && ( {collection?.members[0]?.user && (
<> <>
<p className="text-center text-gray-500 dark:text-gray-300 text-xs sm:text-sm"> <p className="text-center text-neutral text-xs sm:text-sm">
(All Members have <b>Read</b> access to this collection.) (All Members have <b>Read</b> access to this collection.)
</p> </p>
<div className="flex flex-col gap-3 rounded-md"> <div className="flex flex-col gap-3 rounded-md">
@ -193,12 +191,12 @@ export default function TeamManagement({
return ( return (
<div <div
key={i} key={i}
className="relative border p-2 rounded-md border-sky-100 dark:border-neutral-700 flex flex-col sm:flex-row sm:items-center gap-2 justify-between" className="relative border p-2 rounded-md border-neutral flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
> >
{permissions === true && ( {permissions === true && (
<FontAwesomeIcon <FontAwesomeIcon
icon={faClose} icon={faClose}
className="absolute right-2 top-2 text-gray-500 dark:text-gray-300 h-4 hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer" className="absolute right-2 top-2 text-neutral h-4 hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
title="Remove Member" title="Remove Member"
onClick={() => { onClick={() => {
const updatedMembers = collection.members.filter( const updatedMembers = collection.members.filter(
@ -219,25 +217,21 @@ export default function TeamManagement({
className="border-[3px]" className="border-[3px]"
/> />
<div> <div>
<p className="text-sm font-bold text-black dark:text-white"> <p className="text-sm font-bold">{e.user.name}</p>
{e.user.name} <p className="text-neutral">@{e.user.username}</p>
</p>
<p className="text-gray-500 dark:text-gray-300">
@{e.user.username}
</p>
</div> </div>
</div> </div>
<div className="flex sm:block items-center justify-between gap-5 min-w-[10rem]"> <div className="flex sm:block items-center justify-between gap-5 min-w-[10rem]">
<div> <div>
<p <p
className={`font-bold text-sm text-black dark:text-white ${ className={`font-bold text-sm ${
permissions === true ? "" : "mb-2" permissions === true ? "" : "mb-2"
}`} }`}
> >
Permissions Permissions
</p> </p>
{permissions === true && ( {permissions === true && (
<p className="text-xs text-gray-500 dark:text-gray-300 mb-2"> <p className="text-xs text-neutral mb-2">
(Click to toggle.) (Click to toggle.)
</p> </p>
)} )}
@ -247,7 +241,7 @@ export default function TeamManagement({
!e.canCreate && !e.canCreate &&
!e.canUpdate && !e.canUpdate &&
!e.canDelete ? ( !e.canDelete ? (
<p className="text-sm text-gray-500 dark:text-gray-300"> <p className="text-sm text-neutral">
Has no permissions. Has no permissions.
</p> </p>
) : ( ) : (
@ -287,7 +281,7 @@ export default function TeamManagement({
}} }}
/> />
<span <span
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${ className={`peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100" ? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: "" : ""
@ -332,7 +326,7 @@ export default function TeamManagement({
}} }}
/> />
<span <span
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${ className={`peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100" ? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: "" : ""
@ -377,7 +371,7 @@ export default function TeamManagement({
}} }}
/> />
<span <span
className={`text-black dark:text-white peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${ className={`peer-checked:bg-sky-200 dark:peer-checked:bg-sky-600 text-sm ${
permissions === true permissions === true
? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100" ? "hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100"
: "" : ""
@ -397,7 +391,7 @@ export default function TeamManagement({
)} )}
<div <div
className="relative border px-2 rounded-md border-sky-100 dark:border-neutral-700 flex min-h-[7rem] sm:min-h-[5rem] gap-2 justify-between" className="relative border px-2 rounded-md border-neutral-content flex min-h-[7rem] sm:min-h-[5rem] gap-2 justify-between"
title={`'@${collectionOwner.username}' is the owner of this collection.`} title={`'@${collectionOwner.username}' is the owner of this collection.`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -407,21 +401,17 @@ export default function TeamManagement({
/> />
<div> <div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<p className="text-sm font-bold text-black dark:text-white"> <p className="text-sm font-bold">{collectionOwner.name}</p>
{collectionOwner.name}
</p>
<FontAwesomeIcon <FontAwesomeIcon
icon={faCrown} icon={faCrown}
className="w-3 h-3 text-yellow-500" className="w-3 h-3 text-yellow-500"
/> />
</div> </div>
<p className="text-gray-500 dark:text-gray-300"> <p className="text-neutral">@{collectionOwner.username}</p>
@{collectionOwner.username}
</p>
</div> </div>
</div> </div>
<div className="flex flex-col justify-center min-w-[10rem] text-black dark:text-white"> <div className="flex flex-col justify-center min-w-[10rem]">
<p className={`font-bold text-sm`}>Permissions</p> <p className={`font-bold text-sm`}>Permissions</p>
<p>Full Access (Owner)</p> <p>Full Access (Owner)</p>
</div> </div>

View File

@ -33,8 +33,8 @@ export default function ViewTeam({ collection }: Props) {
<p>Here are all the members who are collaborating on this collection.</p> <p>Here are all the members who are collaborating on this collection.</p>
<div <div
className="relative border px-2 rounded-md border-sky-100 dark:border-neutral-700 flex min-h-[4rem] gap-2 justify-between" className="relative border px-2 rounded-md border-neutral flex min-h-[4rem] gap-2 justify-between"
title={`'@${collectionOwner.username}' is the owner of this collection.`} title={`@${collectionOwner.username} is the owner of this collection.`}
> >
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<ProfilePhoto <ProfilePhoto
@ -43,9 +43,7 @@ export default function ViewTeam({ collection }: Props) {
/> />
<div className="w-full"> <div className="w-full">
<div className="flex items-center gap-1 w-full justify-between"> <div className="flex items-center gap-1 w-full justify-between">
<p className="text-sm font-bold text-black dark:text-white"> <p className="text-sm font-bold">{collectionOwner.name}</p>
{collectionOwner.name}
</p>
<div className="flex text-xs gap-1 items-center"> <div className="flex text-xs gap-1 items-center">
<FontAwesomeIcon <FontAwesomeIcon
icon={faCrown} icon={faCrown}
@ -54,9 +52,7 @@ export default function ViewTeam({ collection }: Props) {
Admin Admin
</div> </div>
</div> </div>
<p className="text-gray-500 dark:text-gray-300"> <p className="text-neutral">@{collectionOwner.username}</p>
@{collectionOwner.username}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -70,7 +66,7 @@ export default function ViewTeam({ collection }: Props) {
return ( return (
<div <div
key={i} key={i}
className="relative border p-2 rounded-md border-sky-100 dark:border-neutral-700 flex flex-col sm:flex-row sm:items-center gap-2 justify-between" className="relative border p-2 rounded-md border-neutral flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProfilePhoto <ProfilePhoto
@ -78,12 +74,8 @@ export default function ViewTeam({ collection }: Props) {
className="border-[3px]" className="border-[3px]"
/> />
<div> <div>
<p className="text-sm font-bold text-black dark:text-white"> <p className="text-sm font-bold">{e.user.name}</p>
{e.user.name} <p className="text-neutral">@{e.user.username}</p>
</p>
<p className="text-gray-500 dark:text-gray-300">
@{e.user.username}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@ import { Tab } from "@headlessui/react";
import CollectionInfo from "./CollectionInfo"; import CollectionInfo from "./CollectionInfo";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import TeamManagement from "./TeamManagement"; import TeamManagement from "./TeamManagement";
import { useState } from "react"; import { useEffect, useState } from "react";
import DeleteCollection from "./DeleteCollection"; import DeleteCollection from "./DeleteCollection";
import ViewTeam from "./ViewTeam"; import ViewTeam from "./ViewTeam";
@ -60,7 +60,7 @@ export default function CollectionModal({
</p> </p>
)} )}
{method !== "VIEW_TEAM" && ( {method !== "VIEW_TEAM" && (
<Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5 text-black dark:text-white"> <Tab.List className="flex justify-center flex-col max-w-[15rem] sm:max-w-[30rem] mx-auto sm:flex-row gap-2 sm:gap-3 mb-5">
{method === "UPDATE" && ( {method === "UPDATE" && (
<> <>
{isOwner && ( {isOwner && (

View File

@ -44,6 +44,7 @@ export default function AddOrEditLink({
activeLink || { activeLink || {
name: "", name: "",
url: "", url: "",
type: "",
description: "", description: "",
tags: [], tags: [],
screenshotPath: "", screenshotPath: "",
@ -138,11 +139,11 @@ export default function AddOrEditLink({
<div className="flex flex-col gap-3 sm:w-[35rem] w-80"> <div className="flex flex-col gap-3 sm:w-[35rem] w-80">
{method === "UPDATE" ? ( {method === "UPDATE" ? (
<div <div
className="text-gray-500 dark:text-gray-300 break-all w-full flex gap-2" className="text-neutral break-all w-full flex gap-2"
title={link.url} title={link.url || ""}
> >
<FontAwesomeIcon icon={faLink} className="w-6 h-6" /> <FontAwesomeIcon icon={faLink} className="w-6 h-6" />
<Link href={link.url} target="_blank" className="w-full"> <Link href={link.url || ""} target="_blank" className="w-full">
{link.url} {link.url}
</Link> </Link>
</div> </div>
@ -151,15 +152,16 @@ export default function AddOrEditLink({
{method === "CREATE" ? ( {method === "CREATE" ? (
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3"> <div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5"> <div className="sm:col-span-3 col-span-5">
<p className="text-black dark:text-white mb-2">Address (URL)</p> <p className="mb-2">Address (URL)</p>
<TextInput <TextInput
value={link.url} value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })} onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/" placeholder="e.g. http://example.com/"
className="bg-base-200"
/> />
</div> </div>
<div className="sm:col-span-2 col-span-5"> <div className="sm:col-span-2 col-span-5">
<p className="text-black dark:text-white mb-2">Collection</p> <p className="mb-2">Collection</p>
{link.collection.name ? ( {link.collection.name ? (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
@ -186,20 +188,21 @@ export default function AddOrEditLink({
{optionsExpanded ? ( {optionsExpanded ? (
<div> <div>
{/* <hr className="mb-3 border border-sky-100 dark:border-neutral-700" /> */} {/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}> <div className={`${method === "UPDATE" ? "sm:col-span-2" : ""}`}>
<p className="text-black dark:text-white mb-2">Name</p> <p className="mb-2">Name</p>
<TextInput <TextInput
value={link.name} value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })} onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link" placeholder="e.g. Example Link"
className="bg-base-200"
/> />
</div> </div>
{method === "UPDATE" ? ( {method === "UPDATE" ? (
<div> <div>
<p className="text-black dark:text-white mb-2">Collection</p> <p className="mb-2">Collection</p>
{link.collection.name ? ( {link.collection.name ? (
<CollectionSelection <CollectionSelection
onChange={setCollection} onChange={setCollection}
@ -220,7 +223,7 @@ export default function AddOrEditLink({
) : undefined} ) : undefined}
<div> <div>
<p className="text-black dark:text-white mb-2">Tags</p> <p className="mb-2">Tags</p>
<TagSelection <TagSelection
onChange={setTags} onChange={setTags}
defaultValue={link.tags.map((e) => { defaultValue={link.tags.map((e) => {
@ -230,7 +233,7 @@ export default function AddOrEditLink({
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<p className="text-black dark:text-white mb-2">Description</p> <p className="mb-2">Description</p>
<textarea <textarea
value={unescapeString(link.description) as string} value={unescapeString(link.description) as string}
onChange={(e) => onChange={(e) =>
@ -241,7 +244,7 @@ export default function AddOrEditLink({
? "Will be auto generated if nothing is provided." ? "Will be auto generated if nothing is provided."
: "" : ""
} }
className="resize-none w-full rounded-md p-2 border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100 dark:bg-neutral-950" 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>
@ -253,7 +256,7 @@ export default function AddOrEditLink({
onClick={() => setOptionsExpanded(!optionsExpanded)} onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`${ className={`${
method === "UPDATE" ? "hidden" : "" method === "UPDATE" ? "hidden" : ""
} rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 flex items-center px-2 w-fit text-sm`} } rounded-md cursor-pointer btn btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
> >
<p>{optionsExpanded ? "Hide" : "More"} Options</p> <p>{optionsExpanded ? "Hide" : "More"} Options</p>
</div> </div>

View File

@ -76,8 +76,7 @@ export default function PreservedFormats() {
// Create a temporary link and click it to trigger the download // Create a temporary link and click it to trigger the download
const link = document.createElement("a"); const link = document.createElement("a");
link.href = path; link.href = path;
link.download = link.download = format === ArchivedFormat.png ? "Screenshot" : "PDF";
format === ArchivedFormat.screenshot ? "Screenshot" : "PDF";
link.click(); link.click();
} else { } else {
console.error("Failed to download file"); console.error("Failed to download file");
@ -91,34 +90,38 @@ export default function PreservedFormats() {
return ( return (
<div className={`flex flex-col gap-3 sm:w-[35rem] w-80 pt-3`}> <div className={`flex flex-col gap-3 sm:w-[35rem] w-80 pt-3`}>
{link?.screenshotPath && link?.screenshotPath !== "pending" ? ( {link?.screenshotPath && link?.screenshotPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md"> <div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md"> <div className="bg-primary text-primary-content p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" /> <FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div> </div>
<p className="text-black dark:text-white">Screenshot</p> <p>Screenshot</p>
</div> </div>
<div className="flex text-black dark:text-white gap-1"> <div className="flex gap-1">
<div <div
onClick={() => handleDownload(ArchivedFormat.screenshot)} onClick={() => handleDownload(ArchivedFormat.png)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faCloudArrowDown} icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300" className="w-5 h-5 cursor-pointer text-neutral"
/> />
</div> </div>
<Link <Link
href={`/api/v1/archives/${link?.id}?format=${ArchivedFormat.screenshot}`} href={`/api/v1/archives/${link?.id}?format=${
link.screenshotPath.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`}
target="_blank" target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowUpRightFromSquare} icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300" className="w-5 h-5 text-neutral"
/> />
</Link> </Link>
</div> </div>
@ -126,23 +129,23 @@ export default function PreservedFormats() {
) : undefined} ) : undefined}
{link?.pdfPath && link.pdfPath !== "pending" ? ( {link?.pdfPath && link.pdfPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-sky-100 dark:border-neutral-700 rounded-md"> <div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-l-md"> <div className="bg-primary text-primary-content p-2 rounded-l-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" /> <FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div> </div>
<p className="text-black dark:text-white">PDF</p> <p>PDF</p>
</div> </div>
<div className="flex text-black dark:text-white gap-1"> <div className="flex gap-1">
<div <div
onClick={() => handleDownload(ArchivedFormat.pdf)} onClick={() => handleDownload(ArchivedFormat.pdf)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faCloudArrowDown} icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-gray-500 dark:text-gray-300" className="w-5 h-5 cursor-pointer text-neutral"
/> />
</div> </div>
@ -153,7 +156,7 @@ export default function PreservedFormats() {
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faArrowUpRightFromSquare} icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-gray-500 dark:text-gray-300" className="w-5 h-5 text-neutral"
/> />
</Link> </Link>
</div> </div>
@ -163,7 +166,7 @@ export default function PreservedFormats() {
<div className="flex flex-col-reverse sm:flex-row gap-5 items-center justify-center"> <div className="flex flex-col-reverse sm:flex-row gap-5 items-center justify-center">
{link?.collection.ownerId === session.data?.user.id ? ( {link?.collection.ownerId === session.data?.user.id ? (
<div <div
className={`w-full text-center bg-sky-700 p-1 rounded-md cursor-pointer select-none hover:bg-sky-600 duration-100 ${ className={`btn btn-accent text-white ${
link?.pdfPath && link?.pdfPath &&
link?.screenshotPath && link?.screenshotPath &&
link?.pdfPath !== "pending" && link?.pdfPath !== "pending" &&
@ -173,17 +176,19 @@ export default function PreservedFormats() {
}`} }`}
onClick={() => updateArchive()} onClick={() => updateArchive()}
> >
<p>Update Preserved Formats</p> <div>
<p className="text-xs">(Refresh Link)</p> <p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Link)</p>
</div>
</div> </div>
) : undefined} ) : undefined}
<Link <Link
href={`https://web.archive.org/web/${link?.url.replace( href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//, /(^\w+:|^)\/\//,
"" ""
)}`} )}`}
target="_blank" target="_blank"
className={`text-gray-500 dark:text-gray-300 duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm ${ className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm ${
link?.pdfPath && link?.pdfPath &&
link?.screenshotPath && link?.screenshotPath &&
link?.pdfPath !== "pending" && link?.pdfPath !== "pending" &&

View File

@ -1,34 +0,0 @@
import { MouseEventHandler, ReactNode } from "react";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
type Props = {
toggleModal: Function;
children: ReactNode;
className?: string;
};
export default function Modal({ toggleModal, className, children }: Props) {
return (
<div className="overflow-y-auto py-2 fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex justify-center items-center fade-in z-30">
<ClickAwayHandler
onClickOutside={toggleModal}
className={`m-auto ${className || ""}`}
>
<div className="slide-up relative border-sky-100 dark:border-neutral-700 rounded-2xl border-solid border shadow-lg p-5 bg-white dark:bg-neutral-900">
<div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-5 left-5 inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 z-20 p-2"
>
<FontAwesomeIcon
icon={faChevronLeft}
className="w-4 h-4 text-gray-500 dark:text-gray-300"
/>
</div>
{children}
</div>
</ClickAwayHandler>
</div>
);
}

View File

@ -0,0 +1,139 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast from "react-hot-toast";
import {
faRightFromBracket,
faTrashCan,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useRouter } from "next/router";
import usePermissions from "@/hooks/usePermissions";
import Modal from "../Modal";
type Props = {
onClose: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
};
export default function DeleteCollectionModal({
onClose,
activeCollection,
}: Props) {
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
useEffect(() => {
setCollection(activeCollection);
}, []);
const [submitLoader, setSubmitLoader] = useState(false);
const { removeCollection } = useCollectionStore();
const router = useRouter();
const [inputField, setInputField] = useState("");
const permissions = usePermissions(collection.id as number);
const submit = async () => {
if (permissions === true) if (collection.name !== inputField) return null;
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading("Deleting...");
let response;
response = await removeCollection(collection.id as any);
toast.dismiss(load);
if (response.ok) {
toast.success(`Deleted.`);
onClose();
router.push("/collections");
} else toast.error(response.data as string);
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{permissions === true ? "Delete" : "Leave"} Collection
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
{permissions === true ? (
<>
<div className="flex flex-col gap-3">
<p>
To confirm, type &quot;
<span className="font-bold">{collection.name}</span>
&quot; in the box below:
</p>
<TextInput
value={inputField}
onChange={(e) => setInputField(e.target.value)}
placeholder={`Type "${collection.name}" Here.`}
className="w-3/4 mx-auto"
/>
</div>
<div role="alert" className="alert alert-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>
<b>
Warning: Deleting this collection will permanently erase all
its contents
</b>
, and it will become inaccessible to everyone, including members
with previous access.
</span>
</div>
</>
) : (
<p>Click the button below to leave the current collection.</p>
)}
<button
disabled={permissions === true && inputField !== collection.name}
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 ${
permissions === true
? inputField === collection.name
? "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
: "cursor-not-allowed bg-red-300 dark:bg-red-900"
: "bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer"
}`}
onClick={submit}
>
<FontAwesomeIcon
icon={permissions === true ? faTrashCan : faRightFromBracket}
className="h-5"
/>
{permissions === true ? "Delete" : "Leave"} Collection
</button>
</div>
</Modal>
);
}

View File

@ -0,0 +1,91 @@
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLink, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import Modal from "../Modal";
import { useRouter } from "next/router";
type Props = {
onClose: Function;
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const { removeLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
useEffect(() => {
setLink(activeLink);
}, []);
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const response = await removeLink(link.id as number);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
}
onClose();
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">Delete Link</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>Are you sure you want to delete this Link?</p>
<div role="alert" className="alert alert-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>Warning: This action is irreversible!</span>
</div>
<p>
Hold the <kbd className="kbd kbd-sm">Shift</kbd> key while clicking
'Delete' to bypass this confirmation in the future.
</p>
<button
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
onClick={deleteLink}
>
<FontAwesomeIcon icon={faTrashCan} className="h-5" />
Delete
</button>
</div>
</Modal>
);
}

View File

@ -0,0 +1,122 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast, { Toaster } from "react-hot-toast";
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import { HexColorPicker } from "react-colorful";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal";
type Props = {
onClose: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
};
export default function EditCollectionModal({
onClose,
activeCollection,
}: Props) {
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection } = useCollectionStore();
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading("Updating...");
let response;
response = await updateCollection(collection as any);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Edit Collection Info</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">Name</p>
<div className="flex flex-col gap-3">
<TextInput
className="bg-base-200"
value={collection.name}
placeholder="e.g. Example Collection"
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">Color</p>
<div className="color-picker flex justify-between">
<div className="flex flex-col gap-2 items-center w-32">
<div style={{ color: collection.color }}>
<FontAwesomeIcon
icon={faFolder}
className="w-12 h-12 drop-shadow"
/>
</div>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
Reset
</div>
</div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">Description</p>
<textarea
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder="The purpose of this Collection..."
value={collection.description}
onChange={(e) =>
setCollection({
...collection,
description: e.target.value,
})
}
/>
</div>
</div>
<button
className="btn btn-accent text-white w-fit ml-auto"
onClick={submit}
>
Save
</button>
</div>
</Modal>
);
}

View File

@ -0,0 +1,450 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast, { Toaster } from "react-hot-toast";
import {
faClose,
faCrown,
faUserPlus,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData";
import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions";
import ProfilePhoto from "../ProfilePhoto";
import addMemberToCollection from "@/lib/client/addMemberToCollection";
import Modal from "../Modal";
type Props = {
onClose: Function;
activeCollection: CollectionIncludingMembersAndLinkCount;
};
export default function EditCollectionSharingModal({
onClose,
activeCollection,
}: Props) {
useEffect(() => {
const fetchOwner = async () => {
const owner = await getPublicUserData(collection.ownerId as number);
setCollectionOwner(owner);
};
fetchOwner();
setCollection(activeCollection);
}, []);
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(activeCollection);
const [submitLoader, setSubmitLoader] = useState(false);
const { updateCollection } = useCollectionStore();
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading("Updating...");
let response;
response = await updateCollection(collection as any);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
}
};
const { account } = useAccountStore();
const permissions = usePermissions(collection.id as number);
const currentURL = new URL(document.URL);
const publicCollectionURL = `${currentURL.origin}/public/collections/${collection.id}`;
const [memberUsername, setMemberUsername] = useState("");
const [collectionOwner, setCollectionOwner] = useState({
id: null,
name: "",
username: "",
image: "",
});
const setMemberState = (newMember: Member) => {
if (!collection) return null;
setCollection({
...collection,
members: [...collection.members, newMember],
});
setMemberUsername("");
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">
{permissions === true ? "Share and Collaborate" : "Team"}
</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
{permissions === true && (
<div>
<p>Make Public</p>
<label className="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
checked={collection.isPublic}
onChange={() =>
setCollection({
...collection,
isPublic: !collection.isPublic,
})
}
className="checkbox checkbox-primary"
/>
<span className="label-text">Make this a public collection</span>
</label>
<p className="text-neutral text-sm">
This will let <b>Anyone</b> to view this collection and it's
users.
</p>
</div>
)}
{collection.isPublic ? (
<div className={permissions === true ? "pl-5" : ""}>
<p className="mb-2">Sharable Link (Click to copy)</p>
<div
onClick={() => {
try {
navigator.clipboard
.writeText(publicCollectionURL)
.then(() => toast.success("Copied!"));
} catch (err) {
console.log(err);
}
}}
className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border outline-none hover:border-primary dark:hover:border-primary duration-100 cursor-text"
>
{publicCollectionURL}
</div>
</div>
) : null}
{permissions === true && <div className="divider my-3"></div>}
{permissions === true && (
<>
<p>Member Management</p>
<div className="flex items-center gap-2">
<TextInput
value={memberUsername || ""}
className="bg-base-200"
placeholder="Username (without the '@')"
onChange={(e) => setMemberUsername(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
addMemberToCollection(
account.username as string,
memberUsername || "",
collection,
setMemberState
)
}
/>
<div
onClick={() =>
addMemberToCollection(
account.username as string,
memberUsername || "",
collection,
setMemberState
)
}
className="btn btn-accent text-white btn-square btn-sm h-10 w-10"
>
<FontAwesomeIcon icon={faUserPlus} className="w-5 h-5" />
</div>
</div>
</>
)}
{collection?.members[0]?.user && (
<>
{permissions === true ? (
<p className="text-center text-neutral text-xs sm:text-sm">
(All Members have <b>Read</b> access to this collection.)
</p>
) : (
<p>
Here are all the members who are collaborating on this
collection.
</p>
)}
<div className="flex flex-col gap-3 rounded-md">
<div
className="relative border px-2 rounded-xl border-neutral-content bg-base-200 flex min-h-[6rem] sm:min-h-[4.1rem] gap-2 justify-between"
title={`@${collectionOwner.username} is the owner of this collection.`}
>
<div className="flex items-center gap-2 w-full">
<ProfilePhoto
src={
collectionOwner.image ? collectionOwner.image : undefined
}
name={collectionOwner.name}
/>
<div className="w-full">
<div className="flex items-center gap-1 w-full justify-between">
<p className="text-sm font-bold">
{collectionOwner.name}
</p>
<div className="flex text-xs gap-1 items-center">
<FontAwesomeIcon
icon={faCrown}
className="w-3 h-3 text-yellow-500"
/>
Admin
</div>
</div>
<p className="text-neutral">@{collectionOwner.username}</p>
</div>
</div>
</div>
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
return (
<div
key={i}
className="relative border p-2 rounded-xl border-neutral-content bg-base-200 flex flex-col sm:flex-row sm:items-center gap-2 justify-between"
>
{permissions === true && (
<FontAwesomeIcon
icon={faClose}
className="absolute right-2 top-2 text-neutral h-4 hover:text-red-500 dark:hover:text-red-500 duration-100 cursor-pointer"
title="Remove Member"
onClick={() => {
const updatedMembers = collection.members.filter(
(member) => {
return member.user.username !== e.user.username;
}
);
setCollection({
...collection,
members: updatedMembers,
});
}}
/>
)}
<div className="flex items-center gap-2">
<ProfilePhoto
src={e.user.image ? e.user.image : undefined}
name={e.user.name}
/>
<div>
<p className="text-sm font-bold">{e.user.name}</p>
<p className="text-neutral">@{e.user.username}</p>
</div>
</div>
<div className="flex sm:block items-center justify-between gap-5 min-w-[10rem]">
<div>
<p
className={`font-bold text-sm ${
permissions === true ? "" : "mb-2"
}`}
>
Permissions
</p>
{permissions === true && (
<p className="text-xs text-neutral mb-2">
(Click to toggle.)
</p>
)}
</div>
{permissions !== true &&
!e.canCreate &&
!e.canUpdate &&
!e.canDelete ? (
<p className="text-sm text-neutral">
Has no permissions.
</p>
) : (
<div>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canCreate"
className="peer sr-only"
checked={e.canCreate}
onChange={() => {
if (permissions === true) {
const updatedMembers =
collection.members.map((member) => {
if (
member.user.username ===
e.user.username
) {
return {
...member,
canCreate: !e.canCreate,
};
}
return member;
});
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`peer-checked:bg-primary peer-checked:text-primary-content text-sm ${
permissions === true
? "hover:bg-neutral-content duration-100"
: ""
} rounded p-1 select-none`}
>
Create
</span>
</label>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canUpdate"
className="peer sr-only"
checked={e.canUpdate}
onChange={() => {
if (permissions === true) {
const updatedMembers =
collection.members.map((member) => {
if (
member.user.username ===
e.user.username
) {
return {
...member,
canUpdate: !e.canUpdate,
};
}
return member;
});
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`peer-checked:bg-primary peer-checked:text-primary-content text-sm ${
permissions === true
? "hover:bg-neutral-content duration-100"
: ""
} rounded p-1 select-none`}
>
Update
</span>
</label>
<label
className={
permissions === true
? "cursor-pointer mr-1"
: "mr-1"
}
>
<input
type="checkbox"
id="canDelete"
className="peer sr-only"
checked={e.canDelete}
onChange={() => {
if (permissions === true) {
const updatedMembers =
collection.members.map((member) => {
if (
member.user.username ===
e.user.username
) {
return {
...member,
canDelete: !e.canDelete,
};
}
return member;
});
setCollection({
...collection,
members: updatedMembers,
});
}
}}
/>
<span
className={`peer-checked:bg-primary peer-checked:text-primary-content text-sm ${
permissions === true
? "hover:bg-neutral-content duration-100"
: ""
} rounded p-1 select-none`}
>
Delete
</span>
</label>
</div>
)}
</div>
</div>
);
})}
</div>
</>
)}
{permissions === true && (
<button
className="btn btn-accent text-white w-fit ml-auto mt-3"
onClick={submit}
>
Save
</button>
)}
</div>
</Modal>
);
}

View File

@ -0,0 +1,168 @@
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLink } from "@fortawesome/free-solid-svg-icons";
import Modal from "../Modal";
type Props = {
onClose: Function;
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function EditLinkModal({ onClose, activeLink }: Props) {
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
let shortendURL;
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
const { updateLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
setLink(activeLink);
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
let response;
const load = toast.loading("Updating...");
response = await updateLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(`Updated!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Edit Link</p>
<div className="divider mb-3 mt-1"></div>
{link.url ? (
<Link
href={link.url}
className="truncate text-neutral flex gap-2 mb-5 w-fit max-w-full"
title={link.url}
target="_blank"
>
<FontAwesomeIcon
icon={faLink}
className="mt-1 w-5 h-5 min-w-[1.25rem]"
/>
<p>{shortendURL}</p>
</Link>
) : undefined}
<div className="w-full">
<p className="mb-2">Name</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
className="bg-base-200"
/>
</div>
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
// defaultValue={{
// label: link.collection.name,
// value: link.collection.id,
// }}
defaultValue={
link.collection.id
? {
value: link.collection.id,
label: link.collection.name,
}
: {
value: null as unknown as number,
label: "Unorganized",
}
}
/>
) : null}
</div>
<div>
<p className="mb-2">Tags</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Will be auto generated if nothing is provided."
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button className="btn btn-accent text-white" onClick={submit}>
Save
</button>
</div>
</Modal>
);
}

View File

@ -0,0 +1,254 @@
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faBoxArchive,
faCloudArrowDown,
faFolder,
} from "@fortawesome/free-solid-svg-icons";
import useCollectionStore from "@/store/collections";
import {
faCalendarDays,
faFileImage,
faFilePdf,
} from "@fortawesome/free-regular-svg-icons";
import isValidUrl from "@/lib/shared/isValidUrl";
import unescapeString from "@/lib/client/unescapeString";
import useLocalSettingsStore from "@/store/localSettings";
import Modal from "../Modal";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
onClose: Function;
};
export default function LinkDetails({ link, onClose }: Props) {
const {
settings: { theme },
} = useLocalSettingsStore();
const [imageError, setImageError] = useState<boolean>(false);
const formattedDate = new Date(link.createdAt as string).toLocaleString(
"en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}
);
const { collections } = useCollectionStore();
const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
useEffect(() => {
setCollection(
collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount
);
}, [collections]);
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const colorThief = new ColorThief();
const url = link.url && isValidUrl(link.url) ? new URL(link.url) : undefined;
const handleDownload = (format: "png" | "pdf") => {
const path = `/api/v1/archives/${link.collection.id}/${link.id}.${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === "pdf" ? "PDF" : "Screenshot";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<Modal toggleModal={onClose}>
<div className={`relative flex gap-5 items-start mr-10`}>
{!imageError && url && (
<Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`}
width={42}
height={42}
alt=""
id={"favicon-" + link.id}
className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-white dark:border-neutral-900 bg-white dark:bg-neutral-900 aspect-square"
draggable="false"
onLoad={(e) => {
try {
const color = colorThief.getPalette(
e.target as HTMLImageElement,
4
);
setColorPalette(color);
} catch (err) {
console.log(err);
}
}}
onError={(e) => {
setImageError(true);
}}
/>
)}
<div className="flex w-full flex-col min-h-[3rem] justify-center drop-shadow">
<p className="text-2xl capitalize break-words hyphens-auto">
{unescapeString(link.name)}
</p>
<Link
href={link.url || ""}
target="_blank"
rel="noreferrer"
className={`${
link.name ? "text-sm" : "text-xl"
} text-gray-500 dark:text-gray-300 break-all hover:underline cursor-pointer w-fit`}
>
{url ? url.host : link.url}
</Link>
</div>
</div>
<div className="flex gap-1 items-center flex-wrap">
<Link
href={`/collections/${link.collection.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
<FontAwesomeIcon
icon={faFolder}
className="w-5 h-5 drop-shadow"
style={{ color: collection?.color }}
/>
<p
title={collection?.name}
className="text-lg truncate max-w-[12rem]"
>
{collection?.name}
</p>
</Link>
{link.tags.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="px-2 py-1 bg-sky-200 dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</p>
</Link>
))}
</div>
{link.description && (
<>
<div className="max-h-[20rem] my-3 rounded-md overflow-y-auto hyphens-auto">
{unescapeString(link.description)}
</div>
</>
)}
<div className="flex justify-between items-center">
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-300">
<FontAwesomeIcon icon={faBoxArchive} className="w-4 h-4" />
<p>Archived Formats:</p>
</div>
<div
className="flex items-center gap-1 text-gray-500 dark:text-gray-300"
title={"Created at: " + formattedDate}
>
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center p-2 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p>Screenshot</p>
</div>
<div className="flex gap-1">
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.png`}
target="_blank"
rel="noreferrer"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
<div
onClick={() => handleDownload("png")}
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-sky-500 dark:text-sky-500"
/>
</div>
</div>
</div>
<div className="flex justify-between items-center p-2 border border-sky-100 dark:border-neutral-700 rounded-md">
<div className="flex gap-2 items-center">
<div className="text-white bg-sky-300 dark:bg-sky-600 p-2 rounded-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p>PDF</p>
</div>
<div className="flex gap-1">
<Link
href={`/api/v1/archives/${link.collectionId}/${link.id}.pdf`}
target="_blank"
rel="noreferrer"
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-sky-500 dark:text-sky-500"
/>
</Link>
<div
onClick={() => handleDownload("pdf")}
className="cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-sky-500 dark:text-sky-500"
/>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,127 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import useCollectionStore from "@/store/collections";
import toast, { Toaster } from "react-hot-toast";
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Modal from "../Modal";
type Props = {
onClose: Function;
};
export default function NewCollectionModal({ onClose }: Props) {
const initial = {
name: "",
description: "",
color: "#0ea5e9",
};
const [collection, setCollection] = useState<Partial<Collection>>(initial);
useEffect(() => {
setCollection(initial);
}, []);
const [submitLoader, setSubmitLoader] = useState(false);
const { addCollection } = useCollectionStore();
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
if (!collection) return null;
setSubmitLoader(true);
const load = toast.loading("Creating...");
let response;
response = await addCollection(collection as any);
toast.dismiss(load);
if (response.ok) {
toast.success("Created!");
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create a New Collection</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">Name</p>
<div className="flex flex-col gap-3">
<TextInput
className="bg-base-200"
value={collection.name}
placeholder="e.g. Example Collection"
onChange={(e) =>
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">Color</p>
<div className="color-picker flex justify-between">
<div className="flex flex-col gap-2 items-center w-32">
<div style={{ color: collection.color }}>
<FontAwesomeIcon
icon={faFolder}
className="w-12 h-12 drop-shadow"
/>
</div>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
Reset
</div>
</div>
<HexColorPicker
color={collection.color}
onChange={(e) => setCollection({ ...collection, color: e })}
/>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">Description</p>
<textarea
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder="The purpose of this Collection..."
value={collection.description}
onChange={(e) =>
setCollection({
...collection,
description: e.target.value,
})
}
/>
</div>
</div>
<button
className="btn btn-accent text-white w-fit ml-auto"
onClick={submit}
>
Create Collection
</button>
</div>
</Modal>
);
}

View File

@ -0,0 +1,201 @@
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Modal from "../Modal";
type Props = {
onClose: Function;
};
export default function NewLinkModal({ onClose }: Props) {
const { data } = useSession();
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
screenshotPath: "",
pdfPath: "",
readabilityPath: "",
textContent: "",
collection: {
name: "",
ownerId: data?.user.id as number,
},
} as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const { addLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { collections } = useCollectionStore();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
if (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
setLink({
...initial,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...initial,
collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
});
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
let response;
const load = toast.loading("Creating...");
response = await addLink(link);
toast.dismiss(load);
if (response.ok) {
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Create a New Link</p>
<div className="divider mb-3 mt-1"></div>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="mb-2">Link</p>
<TextInput
value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/"
className="bg-base-200"
/>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={{
label: link.collection.name,
value: link.collection.id,
}}
/>
) : null}
</div>
</div>
{optionsExpanded ? (
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Name</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
className="bg-base-200"
/>
</div>
<div>
<p className="mb-2">Tags</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Will be auto generated if nothing is provided."
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
) : undefined}
<div className="flex justify-between items-center mt-5">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
>
<p>{optionsExpanded ? "Hide" : "More"} Options</p>
</div>
<button className="btn btn-accent text-white" onClick={submit}>
Create Link
</button>
</div>
</Modal>
);
}

View File

@ -0,0 +1,237 @@
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faArrowUpRightFromSquare,
faCloudArrowDown,
faLink,
faTrashCan,
faUpRightFromSquare,
} from "@fortawesome/free-solid-svg-icons";
import Modal from "../Modal";
import { faFileImage, faFilePdf } from "@fortawesome/free-regular-svg-icons";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
type Props = {
onClose: Function;
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function PreservedFormatsModal({ onClose, activeLink }: Props) {
const session = useSession();
const { links, getLink } = useLinkStore();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
const router = useRouter();
useEffect(() => {
let isPublicRoute = router.pathname.startsWith("/public")
? true
: undefined;
(async () => {
const data = await getLink(link.id as number, isPublicRoute);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
})();
let interval: NodeJS.Timer | undefined;
if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") {
interval = setInterval(async () => {
const data = await getLink(link.id as number, isPublicRoute);
setLink(
(data as any).response as LinkIncludingShortenedCollectionAndTags
);
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.screenshotPath, link?.pdfPath, link?.readabilityPath]);
const updateArchive = async () => {
const load = toast.loading("Sending request...");
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
toast.success(`Link is being archived...`);
getLink(link?.id as number);
} else toast.error(data.response);
};
const handleDownload = (format: ArchivedFormat) => {
const path = `/api/v1/archives/${link?.id}?format=${format}`;
fetch(path)
.then((response) => {
if (response.ok) {
// Create a temporary link and click it to trigger the download
const link = document.createElement("a");
link.href = path;
link.download = format === ArchivedFormat.png ? "Screenshot" : "PDF";
link.click();
} else {
console.error("Failed to download file");
}
})
.catch((error) => {
console.error("Error:", error);
});
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">Preserved Formats</p>
<div className="divider mb-3 mt-1"></div>
<div className={`flex flex-col gap-3`}>
{link?.screenshotPath && link?.screenshotPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center">
<div className="bg-primary text-primary-content p-2 rounded-l-md">
<FontAwesomeIcon icon={faFileImage} className="w-6 h-6" />
</div>
<p>Screenshot</p>
</div>
<div className="flex gap-1">
<div
onClick={() => handleDownload(ArchivedFormat.png)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-neutral"
/>
</div>
<Link
href={`/api/v1/archives/${link?.id}?format=${
link.screenshotPath.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faUpRightFromSquare}
className="w-5 h-5 text-neutral"
/>
</Link>
</div>
</div>
) : undefined}
{link?.pdfPath && link.pdfPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center">
<div className="bg-primary text-primary-content p-2 rounded-l-md">
<FontAwesomeIcon icon={faFilePdf} className="w-6 h-6" />
</div>
<p>PDF</p>
</div>
<div className="flex gap-1">
<div
onClick={() => handleDownload(ArchivedFormat.pdf)}
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faCloudArrowDown}
className="w-5 h-5 cursor-pointer text-neutral"
/>
</div>
<Link
href={`/api/v1/archives/${link?.id}?format=${ArchivedFormat.pdf}`}
target="_blank"
className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md"
>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-5 h-5 text-neutral"
/>
</Link>
</div>
</div>
) : undefined}
<div className="flex flex-col-reverse sm:flex-row sm:gap-3 items-center justify-center">
{link?.collection.ownerId === session.data?.user.id ? (
<div
className={`btn btn-accent text-white ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "mt-3"
: ""
}`}
onClick={() => updateArchive()}
>
<div>
<p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Link)</p>
</div>
</div>
) : undefined}
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className={`text-neutral duration-100 hover:opacity-60 flex gap-2 w-fit items-center text-sm ${
link?.pdfPath &&
link?.screenshotPath &&
link?.pdfPath !== "pending" &&
link?.screenshotPath !== "pending"
? "sm:mt-3"
: ""
}`}
>
<p className="whitespace-nowrap">
View Latest Snapshot on archive.org
</p>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="w-4 h-4"
/>
</Link>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,246 @@
import React, { useEffect, useState } from "react";
import { Toaster } from "react-hot-toast";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import Modal from "../Modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faQuestion } from "@fortawesome/free-solid-svg-icons";
import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons";
type Props = {
onClose: Function;
};
export default function UploadFileModal({ onClose }: Props) {
const { data } = useSession();
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
screenshotPath: "",
pdfPath: "",
readabilityPath: "",
textContent: "",
collection: {
name: "",
ownerId: data?.user.id as number,
},
} as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [file, setFile] = useState<File>();
const { addLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter();
const { collections } = useCollectionStore();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => {
return { name: e.label };
});
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
setOptionsExpanded(false);
if (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
setLink({
...initial,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...initial,
collection: {
name: "Unorganized",
ownerId: data?.user.id as number,
},
});
}, []);
const submit = async () => {
if (!submitLoader && file) {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
}
if (fileType !== null && linkType !== null) {
setSubmitLoader(true);
let response;
const load = toast.loading("Creating...");
response = await addLink({
...link,
type: linkType,
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
});
toast.dismiss(load);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${
(response.data as LinkIncludingShortenedCollectionAndTags).id
}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
}
};
return (
<Modal toggleModal={onClose}>
<div className="flex gap-2 items-start">
<p className="text-xl font-thin">Upload File</p>
</div>
<div className="divider mb-3 mt-1"></div>
<div className="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5">
<p className="mb-2">File</p>
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="cursor-pointer custom-file-input"
onChange={(e) => e.target.files && setFile(e.target.files[0])}
/>
</label>
<p className="text-xs font-semibold mt-2">
PDF, PNG, JPG (Up to {process.env.NEXT_PUBLIC_MAX_UPLOAD_SIZE || 30}
MB)
</p>
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">Collection</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={{
label: link.collection.name,
value: link.collection.id,
}}
/>
) : null}
</div>
</div>
{optionsExpanded ? (
<div className="mt-5">
{/* <hr className="mb-3 border border-neutral-content" /> */}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">Name</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder="e.g. Example Link"
className="bg-base-200"
/>
</div>
<div>
<p className="mb-2">Tags</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => {
return { label: e.name, value: e.id };
})}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">Description</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder="Will be auto generated if nothing is provided."
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
) : undefined}
<div className="flex justify-between items-center mt-5">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}
className={`rounded-md cursor-pointer btn btn-sm btn-ghost duration-100 flex items-center px-2 w-fit text-sm`}
>
<p>{optionsExpanded ? "Hide" : "More"} Options</p>
</div>
<button className="btn btn-accent text-white" onClick={submit}>
Create Link
</button>
</div>
</Modal>
);
}

View File

@ -1,42 +1,40 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { faPlus, faBars } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faBars, faCaretDown } from "@fortawesome/free-solid-svg-icons";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Dropdown from "@/components/Dropdown";
import ClickAwayHandler from "@/components/ClickAwayHandler"; import ClickAwayHandler from "@/components/ClickAwayHandler";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals";
import { useTheme } from "next-themes";
import useWindowDimensions from "@/hooks/useWindowDimensions"; import useWindowDimensions from "@/hooks/useWindowDimensions";
import ToggleDarkMode from "./ToggleDarkMode"; import ToggleDarkMode from "./ToggleDarkMode";
import useLocalSettingsStore from "@/store/localSettings";
import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal";
import Link from "next/link";
import UploadFileModal from "./ModalContent/UploadFileModal";
export default function Navbar() { export default function Navbar() {
const { setModal } = useModalStore(); const { settings, updateSettings } = useLocalSettingsStore();
const { account } = useAccountStore(); const { account } = useAccountStore();
const [profileDropdown, setProfileDropdown] = useState(false);
const router = useRouter(); const router = useRouter();
const { theme, setTheme } = useTheme();
const handleToggle = () => {
if (theme === "dark") {
setTheme("light");
} else {
setTheme("dark");
}
};
const [sidebar, setSidebar] = useState(false); const [sidebar, setSidebar] = useState(false);
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const handleToggle = () => {
if (settings.theme === "dark") {
updateSettings({ theme: "light" });
} else {
updateSettings({ theme: "dark" });
}
};
useEffect(() => { useEffect(() => {
setSidebar(false); setSidebar(false);
}, [width]); }, [width]);
@ -49,99 +47,139 @@ export default function Navbar() {
setSidebar(!sidebar); setSidebar(!sidebar);
}; };
const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
return ( return (
<div className="flex justify-between gap-2 items-center px-5 py-2 border-solid border-b-sky-100 dark:border-b-neutral-700 border-b h-16"> <div className="flex justify-between gap-2 items-center px-4 py-2 border-solid border-b-neutral-content border-b">
<div <div
onClick={toggleSidebar} onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-[0.687rem] text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700" className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
> >
<FontAwesomeIcon icon={faBars} className="w-5 h-5" /> <FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div> </div>
<SearchBar /> <SearchBar />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <ToggleDarkMode className="sm:inline-grid hidden" />
onClick={() => {
setModal({ <div className="dropdown dropdown-end">
modal: "LINK", <div className="tooltip tooltip-bottom" data-tip="Create New...">
state: true, <div
method: "CREATE", tabIndex={0}
}); role="button"
}} className="flex items-center group btn btn-accent text-white btn-sm px-2"
className="inline-flex gap-1 relative sm:w-[7.2rem] items-center font-semibold select-none cursor-pointer p-[0.687rem] sm:p-2 sm:px-3 rounded-md sm:rounded-full hover:bg-sky-100 dark:hover:bg-sky-800 sm:dark:hover:bg-sky-600 text-sky-500 sm:text-white sm:bg-sky-700 sm:hover:bg-sky-600 duration-100 group" >
> <FontAwesomeIcon icon={faPlus} className="w-5 h-5" />
<FontAwesomeIcon <FontAwesomeIcon
icon={faPlus} icon={faCaretDown}
className="w-5 h-5 sm:group-hover:ml-9 sm:absolute duration-100" className="w-2 h-2 sm:w-3 sm:h-3"
/> />
<span className="hidden sm:block group-hover:opacity-0 text-right w-full duration-100"> </div>
New Link </div>
</span> <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewLinkModal(true);
}}
tabIndex={0}
role="button"
>
New Link
</div>
</li>
{/* <li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
Upload File
</div>
</li> */}
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
tabIndex={0}
role="button"
>
New Collection
</div>
</li>
</ul>
</div> </div>
<ToggleDarkMode className="sm:flex hidden" /> <div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-circle btn-ghost">
<div className="relative">
<div
className="flex gap-1 group sm:hover:bg-slate-200 sm:hover:dark:bg-neutral-700 sm:hover:p-1 sm:hover:pr-2 duration-100 h-10 rounded-full items-center w-fit cursor-pointer"
onClick={() => setProfileDropdown(!profileDropdown)}
id="profile-dropdown"
>
<ProfilePhoto <ProfilePhoto
src={account.image ? account.image : undefined} src={account.image ? account.image : undefined}
priority={true} priority={true}
className="sm:group-hover:h-8 sm:group-hover:w-8 duration-100 border-[3px]"
/> />
<p
id="profile-dropdown"
className="text-black dark:text-white leading-3 hidden sm:block select-none truncate max-w-[8rem] py-1"
>
{account.name}
</p>
</div> </div>
{profileDropdown ? ( <ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<Dropdown <li>
items={[ <Link
{ href="/settings/account"
name: "Settings", onClick={() => (document?.activeElement as HTMLElement)?.blur()}
href: "/settings/account", tabIndex={0}
}, role="button"
{
name: `Switch to ${theme === "light" ? "Dark" : "Light"}`,
onClick: () => {
handleToggle();
setProfileDropdown(!profileDropdown);
},
},
{
name: "Logout",
onClick: () => {
signOut();
setProfileDropdown(!profileDropdown);
},
},
]}
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "profile-dropdown") setProfileDropdown(false);
}}
className="absolute top-11 right-0 z-20 w-36"
/>
) : null}
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
onClickOutside={toggleSidebar}
> >
<div className="slide-right h-full shadow-lg"> Settings
<Sidebar /> </Link>
</div> </li>
</ClickAwayHandler> <li>
</div> <div
) : null} onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
handleToggle();
}}
tabIndex={0}
role="button"
>
Switch to {settings.theme === "light" ? "Dark" : "Light"}
</div>
</li>
<li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
signOut();
}}
tabIndex={0}
role="button"
>
Logout
</div>
</li>
</ul>
</div> </div>
</div> </div>
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg">
<Sidebar />
</div>
</ClickAwayHandler>
</div>
) : null}
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
</div> </div>
); );
} }

View File

@ -1,40 +1,39 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react"; import React, { useState } from "react";
import useModalStore from "@/store/modals"; import NewLinkModal from "./ModalContent/NewLinkModal";
type Props = { type Props = {
text?: string; text?: string;
}; };
export default function NoLinksFound({ text }: Props) { export default function NoLinksFound({ text }: Props) {
const { setModal } = useModalStore(); const [newLinkModal, setNewLinkModal] = useState(false);
return ( return (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800"> <div className="border border-solid border-neutral-content w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-base-200">
<p className="text-center text-2xl text-black dark:text-white"> <p className="text-center text-2xl">
{text || "You haven't created any Links Here"} {text || "You haven't created any Links Here"}
</p> </p>
<div className="text-center text-black dark:text-white w-full mt-4"> <div className="text-center w-full mt-4">
<div <div
onClick={() => { onClick={() => {
setModal({ setNewLinkModal(true);
modal: "LINK",
state: true,
method: "CREATE",
});
}} }}
className="inline-flex gap-1 relative w-[11.4rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-full dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100 group" className="inline-flex gap-1 relative w-[11rem] items-center btn btn-accent text-white group"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faPlus} icon={faPlus}
className="w-5 h-5 group-hover:ml-[4.325rem] absolute duration-100" className="w-5 h-5 left-4 group-hover:ml-[4rem] absolute duration-100"
/> />
<span className="group-hover:opacity-0 text-right w-full duration-100"> <span className="group-hover:opacity-0 text-right w-full duration-100">
Create New Link Create New Link
</span> </span>
</div> </div>
</div> </div>
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
</div> </div>
); );
} }

View File

@ -6,11 +6,18 @@ import Image from "next/image";
type Props = { type Props = {
src?: string; src?: string;
className?: string; className?: string;
emptyImage?: boolean;
priority?: boolean; priority?: boolean;
name?: string;
dimensionClass?: string;
}; };
export default function ProfilePhoto({ src, className, priority }: Props) { export default function ProfilePhoto({
src,
className,
priority,
name,
dimensionClass,
}: Props) {
const [image, setImage] = useState(""); const [image, setImage] = useState("");
useEffect(() => { useEffect(() => {
@ -24,24 +31,39 @@ export default function ProfilePhoto({ src, className, priority }: Props) {
return !image ? ( return !image ? (
<div <div
className={`bg-sky-600 dark:bg-sky-600 text-white h-10 w-10 aspect-square shadow rounded-full border border-slate-200 dark:border-neutral-700 flex items-center justify-center ${ className={`avatar drop-shadow-md placeholder ${className || ""} ${
className || "" dimensionClass || "w-8 h-8 "
}`} }`}
> >
<FontAwesomeIcon icon={faUser} className="w-1/2 h-1/2 aspect-square" /> <div className="bg-base-100 text-neutral rounded-full w-full h-full ring-2 ring-neutral-content">
{name ? (
<span className="text-2xl capitalize">{name.slice(0, 1)}</span>
) : (
<FontAwesomeIcon
icon={faUser}
className="w-1/2 h-1/2 aspect-square"
/>
)}
</div>
</div> </div>
) : ( ) : (
<Image <div
alt="" className={`avatar drop-shadow-md ${className || ""} ${
src={image} dimensionClass || "w-8 h-8 "
height={112}
width={112}
priority={priority}
draggable={false}
onError={() => setImage("")}
className={`h-10 w-10 bg-sky-600 dark:bg-sky-600 shadow rounded-full aspect-square border border-slate-200 dark:border-neutral-700 ${
className || ""
}`} }`}
/> >
<div className="rounded-full w-full h-full ring-2 ring-neutral-content">
<Image
alt=""
src={image}
height={112}
width={112}
priority={priority}
draggable={false}
onError={() => setImage("")}
className="aspect-square rounded-full"
/>
</div>
</div>
); );
} }

View File

@ -2,7 +2,7 @@ import { faChevronRight } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Image from "next/image"; import Image from "next/image";
import { Link as LinkType, Tag } from "@prisma/client"; import { Link as LinkType, Tag } from "@prisma/client";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import { TagIncludingLinkCount } from "@/types/global"; import { TagIncludingLinkCount } from "@/types/global";
import Link from "next/link"; import Link from "next/link";
@ -17,7 +17,7 @@ type Props = {
}; };
export default function LinkCard({ link, count }: Props) { export default function LinkCard({ link, count }: Props) {
const url = isValidUrl(link.url) ? new URL(link.url) : undefined; const url = link.url && isValidUrl(link.url) ? new URL(link.url) : undefined;
const formattedDate = new Date( const formattedDate = new Date(
link.createdAt as unknown as string link.createdAt as unknown as string
@ -28,7 +28,7 @@ export default function LinkCard({ link, count }: Props) {
}); });
return ( return (
<div className="border border-solid border-sky-100 dark:border-neutral-700 bg-gradient-to-tr from-slate-200 dark:from-neutral-800 from-10% to-gray-50 dark:to-[#303030] via-20% shadow hover:shadow-none duration-100 rounded-lg p-3 flex items-start relative gap-3 group/item"> <div className="border border-solid border-neutral-content bg-base-200 shadow hover:shadow-none duration-100 rounded-lg p-3 flex items-start relative gap-3 group/item">
<div className="flex justify-between items-end gap-5 w-full h-full z-0"> <div className="flex justify-between items-end gap-5 w-full h-full z-0">
<div className="flex flex-col justify-between w-full"> <div className="flex flex-col justify-between w-full">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -57,21 +57,21 @@ export default function LinkCard({ link, count }: Props) {
<Link <Link
href={"/public/collections/20?q=" + e.name} href={"/public/collections/20?q=" + e.name}
key={i} key={i}
className="px-2 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]" className="btn btn-xs btn-ghost truncate max-w-[19rem]"
> >
{e.name} #{e.name}
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
<div className="flex gap-1 items-center flex-wrap text-sm text-gray-500 dark:text-gray-300"> <div className="flex gap-1 items-center flex-wrap text-sm text-neutral">
<p>{formattedDate}</p> <p>{formattedDate}</p>
<p>·</p> <p>·</p>
<Link <Link
href={url ? url.href : link.url} href={url ? url.href : link.url || ""}
target="_blank" target="_blank"
className="hover:opacity-50 duration-100 truncate w-52 sm:w-fit" className="hover:opacity-50 duration-100 truncate w-52 sm:w-fit"
title={url ? url.href : link.url} title={url ? url.href : link.url || ""}
> >
{url ? url.host : link.url} {url ? url.host : link.url}
</Link> </Link>
@ -80,7 +80,7 @@ export default function LinkCard({ link, count }: Props) {
{unescapeString(link.description)}{" "} {unescapeString(link.description)}{" "}
<Link <Link
href={`/public/links/${link.id}`} href={`/public/links/${link.id}`}
className="flex gap-1 items-center flex-wrap text-sm text-gray-500 dark:text-gray-300 hover:opacity-50 duration-100 min-w-fit float-right mt-1 ml-2" className="flex gap-1 items-center flex-wrap text-sm text-neutral hover:opacity-50 duration-100 min-w-fit float-right mt-1 ml-2"
> >
<p>Read</p> <p>Read</p>
<FontAwesomeIcon <FontAwesomeIcon

View File

@ -1,59 +0,0 @@
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { toast } from "react-hot-toast";
type Props = {
placeHolder?: string;
};
export default function PublicSearchBar({ placeHolder }: Props) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState<string>("");
useEffect(() => {
router.query.q
? setSearchQuery(decodeURIComponent(router.query.q as string))
: setSearchQuery("");
}, [router.query.q]);
return (
<div className="flex items-center relative group">
<label
htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md text-sky-500 dark:text-sky-500"
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="w-4 h-4" />
</label>
<input
id="search-box"
type="text"
placeholder={placeHolder}
value={searchQuery}
onChange={(e) => {
e.target.value.includes("%") &&
toast.error("The search query should not contain '%'.");
setSearchQuery(e.target.value.replace("%", ""));
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (!searchQuery) {
return router.push("/public/collections/" + router.query.id);
}
return router.push(
"/public/collections/" +
router.query.id +
"?q=" +
encodeURIComponent(searchQuery || "")
);
}
}}
className="border text-sm border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-8 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800"
/>
</div>
);
}

View File

@ -20,15 +20,13 @@ export default function RadioButton({ label, state, onClick }: Props) {
/> />
<FontAwesomeIcon <FontAwesomeIcon
icon={faCircleCheck} icon={faCircleCheck}
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:block hidden" className="w-5 h-5 text-primary peer-checked:block hidden"
/> />
<FontAwesomeIcon <FontAwesomeIcon
icon={faCircle} icon={faCircle}
className="w-5 h-5 text-sky-500 dark:text-sky-500 peer-checked:hidden block" className="w-5 h-5 text-primary peer-checked:hidden block"
/> />
<span className="text-black dark:text-white rounded select-none"> <span className="rounded select-none">{label}</span>
{label}
</span>
</label> </label>
); );
} }

View File

@ -1,11 +0,0 @@
export default function RequiredBadge() {
return (
<span
title="Required Field"
className="text-black dark:text-white cursor-help"
>
{" "}
*
</span>
);
}

View File

@ -1,23 +1,29 @@
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
export default function SearchBar() { type Props = {
placeholder?: string;
};
export default function SearchBar({ placeholder }: Props) {
const router = useRouter(); const router = useRouter();
const routeQuery = router.query.q; const [searchQuery, setSearchQuery] = useState("");
const [searchQuery, setSearchQuery] = useState( useEffect(() => {
routeQuery ? decodeURIComponent(routeQuery as string) : "" router.query.q
); ? setSearchQuery(decodeURIComponent(router.query.q as string))
: setSearchQuery("");
}, [router.query.q]);
return ( return (
<div className="flex items-center relative group"> <div className="flex items-center relative group">
<label <label
htmlFor="search-box" htmlFor="search-box"
className="inline-flex w-fit absolute left-2 pointer-events-none rounded-md p-1 text-sky-500 dark:text-sky-500" className="inline-flex w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary"
> >
<FontAwesomeIcon icon={faMagnifyingGlass} className="w-5 h-5" /> <FontAwesomeIcon icon={faMagnifyingGlass} className="w-5 h-5" />
</label> </label>
@ -25,18 +31,34 @@ export default function SearchBar() {
<input <input
id="search-box" id="search-box"
type="text" type="text"
placeholder="Search for Links" placeholder={placeholder || "Search for Links"}
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => {
e.target.value.includes("%") && e.target.value.includes("%") &&
toast.error("The search query should not contain '%'."); toast.error("The search query should not contain '%'.");
setSearchQuery(e.target.value.replace("%", "")); setSearchQuery(e.target.value.replace("%", ""));
}} }}
onKeyDown={(e) => onKeyDown={(e) => {
e.key === "Enter" && if (e.key === "Enter") {
router.push("/search?q=" + encodeURIComponent(searchQuery)) if (router.pathname.startsWith("/public")) {
} if (!searchQuery) {
className="border border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-10 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800" return router.push("/public/collections/" + router.query.id);
}
return router.push(
"/public/collections/" +
router.query.id +
"?q=" +
encodeURIComponent(searchQuery || "")
);
} else {
return router.push(
"/search?q=" + encodeURIComponent(searchQuery)
);
}
}
}}
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-44 sm:w-60 md:focus:w-80 duration-100 outline-none"
/> />
</div> </div>
); );

View File

@ -21,7 +21,7 @@ import {
} from "@fortawesome/free-brands-svg-icons"; } from "@fortawesome/free-brands-svg-icons";
export default function SettingsSidebar({ className }: { className?: string }) { export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.3.0"; const LINKWARDEN_VERSION = "v2.4.0";
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
@ -35,7 +35,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
return ( return (
<div <div
className={`dark:bg-neutral-900 bg-white h-full w-64 overflow-y-auto border-solid border-white border dark:border-neutral-900 border-r-sky-100 dark:border-r-neutral-700 p-5 z-20 flex flex-col gap-5 justify-between ${ className={`bg-base-100 h-full w-64 overflow-y-auto border-solid border border-base-100 border-r-neutral-content p-5 z-20 flex flex-col gap-5 justify-between ${
className || "" className || ""
}`} }`}
> >
@ -44,18 +44,13 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`${ className={`${
active === `/settings/account` active === `/settings/account`
? "bg-sky-500" ? "bg-primary/20"
: "hover:bg-slate-500" : "hover:bg-neutral/20"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon icon={faUser} className="w-7 h-7 text-primary" />
icon={faUser}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">Account</p>
Account
</p>
</div> </div>
</Link> </Link>
@ -63,18 +58,16 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`${ className={`${
active === `/settings/appearance` active === `/settings/appearance`
? "bg-sky-500" ? "bg-primary/20"
: "hover:bg-slate-500" : "hover:bg-neutral/20"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faPalette} icon={faPalette}
className="w-6 h-6 text-sky-500 dark:text-sky-500" className="w-7 h-7 text-primary"
/> />
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">Appearance</p>
Appearance
</p>
</div> </div>
</Link> </Link>
@ -82,35 +75,30 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`${ className={`${
active === `/settings/archive` active === `/settings/archive`
? "bg-sky-500" ? "bg-primary/20"
: "hover:bg-slate-500" : "hover:bg-neutral/20"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faBoxArchive} icon={faBoxArchive}
className="w-6 h-6 text-sky-500 dark:text-sky-500" className="w-7 h-7 text-primary"
/> />
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">Archive</p>
Archive
</p>
</div> </div>
</Link> </Link>
<Link href="/settings/api"> <Link href="/settings/api">
<div <div
className={`${ className={`${
active === `/settings/api` ? "bg-sky-500" : "hover:bg-slate-500" active === `/settings/api`
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} ? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon icon={faKey} className="w-7 h-7 text-primary" />
icon={faKey}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">API Keys</p>
API Keys
</p>
</div> </div>
</Link> </Link>
@ -118,18 +106,13 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`${ className={`${
active === `/settings/password` active === `/settings/password`
? "bg-sky-500" ? "bg-primary/20"
: "hover:bg-slate-500" : "hover:bg-neutral/20"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon icon={faLock} className="w-7 h-7 text-primary" />
icon={faLock}
className="w-6 h-6 text-sky-500 dark:text-sky-500"
/>
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">Password</p>
Password
</p>
</div> </div>
</Link> </Link>
@ -138,18 +121,16 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<div <div
className={`${ className={`${
active === `/settings/billing` active === `/settings/billing`
? "bg-sky-500" ? "bg-primary/20"
: "hover:bg-slate-500" : "hover:bg-neutral/20"
} duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faCreditCard} icon={faCreditCard}
className="w-6 h-6 text-sky-500 dark:text-sky-500" className="w-7 h-7 text-primary"
/> />
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">Billing</p>
Billing
</p>
</div> </div>
</Link> </Link>
) : undefined} ) : undefined}
@ -159,67 +140,59 @@ export default function SettingsSidebar({ className }: { className?: string }) {
<Link <Link
href={`https://github.com/linkwarden/linkwarden/releases`} href={`https://github.com/linkwarden/linkwarden/releases`}
target="_blank" target="_blank"
className="dark:text-gray-300 text-gray-500 text-sm ml-2 hover:opacity-50 duration-100" className="text-neutral text-sm ml-2 hover:opacity-50 duration-100"
> >
Linkwarden {LINKWARDEN_VERSION} Linkwarden {LINKWARDEN_VERSION}
</Link> </Link>
<Link href="https://docs.linkwarden.app" target="_blank"> <Link href="https://docs.linkwarden.app" target="_blank">
<div <div
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faCircleQuestion as any} icon={faCircleQuestion as any}
className="w-6 h-6 text-sky-500 dark:text-sky-500" className="w-6 h-6 text-primary"
/> />
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">Help</p>
Help
</p>
</div> </div>
</Link> </Link>
<Link href="https://github.com/linkwarden/linkwarden" target="_blank"> <Link href="https://github.com/linkwarden/linkwarden" target="_blank">
<div <div
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faGithub as any} icon={faGithub as any}
className="w-6 h-6 text-sky-500 dark:text-sky-500" className="w-6 h-6 text-primary"
/> />
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">GitHub</p>
GitHub
</p>
</div> </div>
</Link> </Link>
<Link href="https://twitter.com/LinkwardenHQ" target="_blank"> <Link href="https://twitter.com/LinkwardenHQ" target="_blank">
<div <div
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faXTwitter as any} icon={faXTwitter as any}
className="w-6 h-6 text-sky-500 dark:text-sky-500" className="w-6 h-6 text-primary"
/> />
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">Twitter</p>
Twitter
</p>
</div> </div>
</Link> </Link>
<Link href="https://fosstodon.org/@linkwarden" target="_blank"> <Link href="https://fosstodon.org/@linkwarden" target="_blank">
<div <div
className={`hover:bg-slate-500 duration-100 py-2 px-2 hover:bg-opacity-20 bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} className={`hover:bg-neutral/20 duration-100 py-2 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faMastodon as any} icon={faMastodon as any}
className="w-6 h-6 text-sky-500 dark:text-sky-500" className="w-6 h-6 text-primary"
/> />
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">Mastodon</p>
Mastodon
</p>
</div> </div>
</Link> </Link>
</div> </div>

View File

@ -52,7 +52,7 @@ export default function Sidebar({ className }: { className?: string }) {
return ( return (
<div <div
className={`bg-gray-100 dark:bg-neutral-800 h-full w-64 xl:w-80 overflow-y-auto border-solid border dark:border-neutral-800 border-r-sky-100 dark:border-r-neutral-700 px-2 z-20 ${ className={`bg-base-200 h-full w-64 xl:w-80 overflow-y-auto border-solid border border-base-200 border-r-neutral-content px-2 z-20 ${
className || "" className || ""
}`} }`}
> >
@ -60,64 +60,60 @@ export default function Sidebar({ className }: { className?: string }) {
<Link href={`/dashboard`}> <Link href={`/dashboard`}>
<div <div
className={`${ className={`${
active === `/dashboard` ? "bg-sky-500" : "hover:bg-slate-500" active === `/dashboard` ? "bg-primary/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faChartSimple} icon={faChartSimple}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500" className="w-7 h-7 drop-shadow text-primary"
/> />
<p className="text-black dark:text-white truncate w-full"> <p className="truncate w-full">Dashboard</p>
Dashboard
</p>
</div> </div>
</Link> </Link>
<Link href={`/links`}> <Link href={`/links`}>
<div <div
className={`${ className={`${
active === `/links` ? "bg-sky-500" : "hover:bg-slate-500" active === `/links` ? "bg-primary/20" : "hover:bg-neutral/20"
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`} } duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faLink} icon={faLink}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500" className="w-7 h-7 drop-shadow text-primary"
/> />
<p className="text-black dark:text-white truncate w-full"> <p className="truncate w-full">All Links</p>
All Links
</p>
</div> </div>
</Link> </Link>
<Link href={`/collections`}> <Link href={`/collections`}>
<div <div
className={`${ className={`${
active === `/collections` ? "bg-sky-500" : "hover:bg-slate-500" active === `/collections`
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`} ? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500" className="w-7 h-7 drop-shadow text-primary"
/> />
<p className="text-black dark:text-white truncate w-full"> <p className="truncate w-full">All Collections</p>
All Collections
</p>
</div> </div>
</Link> </Link>
<Link href={`/links/pinned`}> <Link href={`/links/pinned`}>
<div <div
className={`${ className={`${
active === `/links/pinned` ? "bg-sky-500" : "hover:bg-slate-500" active === `/links/pinned`
} duration-100 py-5 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`} ? "bg-primary/20"
: "hover:bg-neutral/20"
} duration-100 py-5 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faThumbTack} icon={faThumbTack}
className="w-7 h-7 drop-shadow text-sky-500 dark:text-sky-500" className="w-7 h-7 drop-shadow text-primary"
/> />
<p className="text-black dark:text-white truncate w-full"> <p className="truncate w-full">Pinned Links</p>
Pinned Links
</p>
</div> </div>
</Link> </Link>
</div> </div>
@ -127,7 +123,7 @@ export default function Sidebar({ className }: { className?: string }) {
onClick={() => { onClick={() => {
setCollectionDisclosure(!collectionDisclosure); setCollectionDisclosure(!collectionDisclosure);
}} }}
className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-gray-500 dark:text-gray-300 mt-5" className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
> >
<p>Collections</p> <p>Collections</p>
@ -156,27 +152,25 @@ export default function Sidebar({ className }: { className?: string }) {
<div <div
className={`${ className={`${
active === `/collections/${e.id}` active === `/collections/${e.id}`
? "bg-sky-500" ? "bg-primary/20"
: "hover:bg-slate-500" : "hover:bg-neutral/20"
} duration-100 py-1 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`} } duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
className="w-6 h-6 drop-shadow" className="w-6 h-6 drop-shadow"
style={{ color: e.color }} style={{ color: e.color }}
/> />
<p className="text-black dark:text-white truncate w-full"> <p className="truncate w-full">{e.name}</p>
{e.name}
</p>
{e.isPublic ? ( {e.isPublic ? (
<FontAwesomeIcon <FontAwesomeIcon
icon={faGlobe} icon={faGlobe}
title="This collection is being shared publicly." title="This collection is being shared publicly."
className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300" className="w-4 h-4 drop-shadow text-neutral"
/> />
) : undefined} ) : undefined}
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs"> <div className="drop-shadow text-neutral text-xs">
{e._count?.links} {e._count?.links}
</div> </div>
</div> </div>
@ -187,7 +181,7 @@ export default function Sidebar({ className }: { className?: string }) {
<div <div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`} className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
> >
<p className="text-gray-500 dark:text-gray-300 text-xs font-semibold truncate w-full pr-7"> <p className="text-neutral text-xs font-semibold truncate w-full pr-7">
You Have No Collections... You Have No Collections...
</p> </p>
</div> </div>
@ -200,7 +194,7 @@ export default function Sidebar({ className }: { className?: string }) {
onClick={() => { onClick={() => {
setTagDisclosure(!tagDisclosure); setTagDisclosure(!tagDisclosure);
}} }}
className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-gray-500 dark:text-gray-300 mt-5" className="flex items-center justify-between text-sm w-full text-left mb-2 pl-2 font-bold text-neutral mt-5"
> >
<p>Tags</p> <p>Tags</p>
<FontAwesomeIcon <FontAwesomeIcon
@ -226,19 +220,17 @@ export default function Sidebar({ className }: { className?: string }) {
<div <div
className={`${ className={`${
active === `/tags/${e.id}` active === `/tags/${e.id}`
? "bg-sky-500" ? "bg-primary/20"
: "hover:bg-slate-500" : "hover:bg-neutral/20"
} duration-100 py-1 px-2 bg-opacity-20 hover:bg-opacity-20 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`} } duration-100 py-1 px-2 cursor-pointer flex items-center gap-2 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faHashtag} icon={faHashtag}
className="w-4 h-4 text-sky-500 dark:text-sky-500 mt-1" className="w-4 h-4 text-primary mt-1"
/> />
<p className="text-black dark:text-white truncate w-full pr-7"> <p className="truncate w-full pr-7">{e.name}</p>
{e.name} <div className="drop-shadow text-neutral text-xs">
</p>
<div className="drop-shadow text-gray-500 dark:text-gray-300 text-xs">
{e._count?.links} {e._count?.links}
</div> </div>
</div> </div>
@ -249,7 +241,7 @@ export default function Sidebar({ className }: { className?: string }) {
<div <div
className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`} className={`duration-100 py-1 px-2 flex items-center gap-2 w-full rounded-md h-8 capitalize`}
> >
<p className="text-gray-500 dark:text-gray-300 text-xs font-semibold truncate w-full pr-7"> <p className="text-neutral text-xs font-semibold truncate w-full pr-7">
You Have No Tags... You Have No Tags...
</p> </p>
</div> </div>

View File

@ -1,68 +1,133 @@
import React, { Dispatch, SetStateAction } from "react"; import React, { Dispatch, SetStateAction } from "react";
import ClickAwayHandler from "./ClickAwayHandler";
import RadioButton from "./RadioButton";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSort } from "@fortawesome/free-solid-svg-icons";
type Props = { type Props = {
sortBy: Sort; sortBy: Sort;
setSort: Dispatch<SetStateAction<Sort>>; setSort: Dispatch<SetStateAction<Sort>>;
toggleSortDropdown: Function;
}; };
export default function SortDropdown({ export default function SortDropdown({ sortBy, setSort }: Props) {
sortBy,
toggleSortDropdown,
setSort,
}: Props) {
return ( return (
<ClickAwayHandler <div className="dropdown dropdown-bottom dropdown-end">
onClickOutside={(e: Event) => { <div
const target = e.target as HTMLInputElement; tabIndex={0}
if (target.id !== "sort-dropdown") toggleSortDropdown(); role="button"
}} className="btn btn-sm btn-square btn-ghost"
className="absolute top-8 right-0 border border-sky-100 dark:border-neutral-700 shadow-md bg-gray-50 dark:bg-neutral-800 rounded-md p-2 z-20 w-52" >
> <FontAwesomeIcon
<p className="mb-2 text-black dark:text-white text-center font-semibold"> icon={faSort}
Sort by id="sort-dropdown"
</p> className="w-5 h-5 text-neutral"
<div className="flex flex-col gap-2">
<RadioButton
label="Date (Newest First)"
state={sortBy === Sort.DateNewestFirst}
onClick={() => setSort(Sort.DateNewestFirst)}
/>
<RadioButton
label="Date (Oldest First)"
state={sortBy === Sort.DateOldestFirst}
onClick={() => setSort(Sort.DateOldestFirst)}
/>
<RadioButton
label="Name (A-Z)"
state={sortBy === Sort.NameAZ}
onClick={() => setSort(Sort.NameAZ)}
/>
<RadioButton
label="Name (Z-A)"
state={sortBy === Sort.NameZA}
onClick={() => setSort(Sort.NameZA)}
/>
<RadioButton
label="Description (A-Z)"
state={sortBy === Sort.DescriptionAZ}
onClick={() => setSort(Sort.DescriptionAZ)}
/>
<RadioButton
label="Description (Z-A)"
state={sortBy === Sort.DescriptionZA}
onClick={() => setSort(Sort.DescriptionZA)}
/> />
</div> </div>
</ClickAwayHandler> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-xl w-52 mt-1">
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Date (Newest First)"
checked={sortBy === Sort.DateNewestFirst}
onChange={() => {
setSort(Sort.DateNewestFirst);
}}
/>
<span className="label-text">Date (Newest First)</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Date (Oldest First)"
checked={sortBy === Sort.DateOldestFirst}
onChange={() => setSort(Sort.DateOldestFirst)}
/>
<span className="label-text">Date (Oldest First)</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Name (A-Z)"
checked={sortBy === Sort.NameAZ}
onChange={() => setSort(Sort.NameAZ)}
/>
<span className="label-text">Name (A-Z)</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Name (Z-A)"
checked={sortBy === Sort.NameZA}
onChange={() => setSort(Sort.NameZA)}
/>
<span className="label-text">Name (Z-A)</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Description (A-Z)"
checked={sortBy === Sort.DescriptionAZ}
onChange={() => setSort(Sort.DescriptionAZ)}
/>
<span className="label-text">Description (A-Z)</span>
</label>
</li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="radio"
name="sort-radio"
className="radio checked:bg-primary"
value="Description (Z-A)"
checked={sortBy === Sort.DescriptionZA}
onChange={() => setSort(Sort.DescriptionZA)}
/>
<span className="label-text">Description (Z-A)</span>
</label>
</li>
</ul>
</div>
); );
} }

View File

@ -21,17 +21,15 @@ export default function SubmitButton({
return ( return (
<button <button
type={type ? type : undefined} type={type ? type : undefined}
className={`text-white flex items-center gap-2 py-2 px-5 rounded-md text-lg tracking-wide select-none font-semibold duration-100 w-fit ${ className={`btn btn-accent text-white tracking-wider w-fit flex items-center gap-2 ${
loading className || ""
? "bg-sky-600 cursor-auto" }`}
: "bg-sky-700 hover:bg-sky-600 cursor-pointer"
} ${className || ""}`}
onClick={() => { onClick={() => {
if (!loading && onClick) onClick(); if (!loading && onClick) onClick();
}} }}
> >
{icon && <FontAwesomeIcon icon={icon} className="h-5" />} {icon && <FontAwesomeIcon icon={icon} className="h-5" />}
<p className="text-center w-full">{label}</p> <p>{label}</p>
</button> </button>
); );
} }

View File

@ -27,7 +27,7 @@ export default function TextInput({
value={value} value={value}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
className={`w-full rounded-md p-2 border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-950 border-solid border outline-none focus:border-sky-300 focus:dark:border-sky-600 duration-100 ${ className={`w-full rounded-md p-2 border-neutral-content border-solid border outline-none focus:border-primary duration-100 ${
className || "" className || ""
}`} }`}
/> />

View File

@ -1,31 +1,63 @@
import { useTheme } from "next-themes"; import useLocalSettingsStore from "@/store/localSettings";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useEffect, useState } from "react";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
type Props = { type Props = {
className?: string; className?: string;
}; };
export default function ToggleDarkMode({ className }: Props) { export default function ToggleDarkMode({ className }: Props) {
const { theme, setTheme } = useTheme(); const { settings, updateSettings } = useLocalSettingsStore();
const handleToggle = () => { const [theme, setTheme] = useState(localStorage.getItem("theme"));
if (theme === "dark") {
setTheme("light"); const handleToggle = (e: any) => {
} else { if (e.target.checked) {
setTheme("dark"); setTheme("dark");
} else {
setTheme("light");
} }
}; };
useEffect(() => {
updateSettings({ theme: theme as string });
}, [theme]);
return ( return (
<div <div
className={`cursor-pointer flex select-none border border-sky-600 items-center justify-center dark:bg-neutral-900 bg-white hover:border-sky-500 group duration-100 rounded-full text-white w-10 h-10 ${className}`} className="tooltip tooltip-bottom"
onClick={handleToggle} data-tip={`Switch to ${settings.theme === "light" ? "Dark" : "Light"}`}
> >
<FontAwesomeIcon <label
icon={theme === "dark" ? faSun : faMoon} className={`swap swap-rotate btn-square text-neutral btn btn-ghost btn-sm ${className}`}
className="w-1/2 h-1/2 text-sky-600 group-hover:text-sky-500" >
/> <input
type="checkbox"
onChange={handleToggle}
className="theme-controller"
checked={localStorage.getItem("theme") === "light" ? false : true}
/>
{/* sun icon */}
<svg
className="swap-on fill-current w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8M8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0m0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13m8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5M3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8m10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 1 1-.707-.708l1.414-1.414a.5.5 0 0 1 .707 0m-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707M4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" />
</svg>
{/* moon icon */}
<svg
className="swap-off fill-current w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278" />
</svg>
</label>
</div> </div>
); );
} }

View File

@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useTagStore from "@/store/tags"; import useTagStore from "@/store/tags";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import useLocalSettingsStore from "@/store/localSettings";
export default function useInitialData() { export default function useInitialData() {
const { status, data } = useSession(); const { status, data } = useSession();
@ -10,10 +11,12 @@ export default function useInitialData() {
const { setTags } = useTagStore(); const { setTags } = useTagStore();
// const { setLinks } = useLinkStore(); // const { setLinks } = useLinkStore();
const { account, setAccount } = useAccountStore(); const { account, setAccount } = useAccountStore();
const { setSettings } = useLocalSettingsStore();
// Get account info
useEffect(() => { useEffect(() => {
setSettings();
if (status === "authenticated") { if (status === "authenticated") {
// Get account info
setAccount(data?.user.id as number); setAccount(data?.user.id as number);
} }
}, [status, data]); }, [status, data]);

View File

@ -1,7 +1,7 @@
import { useTheme } from "next-themes"; import useLocalSettingsStore from "@/store/localSettings";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import React, { ReactNode } from "react"; import React, { ReactNode, useEffect } from "react";
interface Props { interface Props {
text?: string; text?: string;
@ -9,44 +9,29 @@ interface Props {
} }
export default function CenteredForm({ text, children }: Props) { export default function CenteredForm({ text, children }: Props) {
const { theme } = useTheme(); const { settings } = useLocalSettingsStore();
return ( return (
<div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5"> <div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center p-5">
<div className="m-auto flex flex-col gap-2 w-full"> <div className="m-auto flex flex-col gap-2 w-full">
{theme ? ( {settings.theme ? (
<Image <Image
src={`/linkwarden_${theme === "dark" ? "dark" : "light"}.png`} src={`/linkwarden_${
settings.theme === "dark" ? "dark" : "light"
}.png`}
width={640} width={640}
height={136} height={136}
alt="Linkwarden" alt="Linkwarden"
className="h-12 w-fit mx-auto" className="h-12 w-fit mx-auto"
/> />
) : undefined} ) : undefined}
{/* {theme === "dark" ? (
<Image
src="/linkwarden_dark.png"
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
) : (
<Image
src="/linkwarden_light.png"
width={640}
height={136}
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
)} */}
{text ? ( {text ? (
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold text-black dark:text-white px-2 text-center"> <p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center">
{text} {text}
</p> </p>
) : undefined} ) : undefined}
{children} {children}
<p className="text-center text-xs text-gray-500 mb-5 dark:text-gray-400"> <p className="text-center text-xs text-neutral mb-5">
© {new Date().getFullYear()}{" "} © {new Date().getFullYear()}{" "}
<Link href="https://linkwarden.app" className="font-semibold"> <Link href="https://linkwarden.app" className="font-semibold">
Linkwarden Linkwarden

View File

@ -19,6 +19,11 @@ import {
} from "@/types/global"; } from "@/types/global";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import EditLinkModal from "@/components/ModalContent/EditLinkModal";
import Link from "next/link";
import PreservedFormatsModal from "@/components/ModalContent/PreservedFormatsModal";
import toast from "react-hot-toast";
import DeleteLinkModal from "@/components/ModalContent/DeleteLinkModal";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@ -73,93 +78,81 @@ export default function LinkLayout({ children }: Props) {
setLinkCollection(collections.find((e) => e.id === link?.collection?.id)); setLinkCollection(collections.find((e) => e.id === link?.collection?.id));
}, [link, collections]); }, [link, collections]);
const deleteLink = async () => {
const load = toast.loading("Deleting...");
const response = await removeLink(link?.id as number);
toast.dismiss(load);
response.ok && toast.success(`Link Deleted.`);
router.push("/dashboard");
};
const [editLinkModal, setEditLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
return ( return (
<> <>
<ModalManagement />
<div className="flex mx-auto"> <div className="flex mx-auto">
{/* <div className="hidden lg:block fixed left-5 h-screen"> {/* <div className="hidden lg:block fixed left-5 h-screen">
<LinkSidebar /> <LinkSidebar />
</div> */} </div> */}
<div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5"> <div className="w-full flex flex-col min-h-screen max-w-screen-md mx-auto p-5">
<div className="flex gap-3 mb-5 duration-100 items-center justify-between"> <div className="flex gap-3 mb-3 duration-100 items-center justify-between">
{/* <div {/* <div
onClick={toggleSidebar} onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700" className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-neutral rounded-md duration-100 hover:bg-neutral-content"
> >
<FontAwesomeIcon icon={faBars} className="w-5 h-5" /> <FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div> */} </div> */}
<div <Link
onClick={() => { href={
if (router.pathname.startsWith("/public")) { router.pathname.startsWith("/public")
router.push( ? `/public/collections/${
`/public/collections/${
linkCollection?.id || link?.collection.id linkCollection?.id || link?.collection.id
}` }`
); : `/dashboard`
} else { }
router.push(`/dashboard`); className="inline-flex gap-1 btn btn-ghost btn-sm text-neutral px-2"
}
}}
className="inline-flex gap-1 hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100"
> >
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" /> <FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back{" "} <span className="capitalize">
<span className="hidden sm:inline-block"> {router.pathname.startsWith("/public")
to{" "} ? linkCollection?.name || link?.collection?.name
<span className="capitalize"> : "Dashboard"}
{router.pathname.startsWith("/public")
? linkCollection?.name || link?.collection?.name
: "Dashboard"}
</span>
</span> </span>
</div> </Link>
<div className="flex gap-5"> <div className="flex gap-3">
{link?.collection?.ownerId === userId || {link?.collection?.ownerId === userId ||
linkCollection?.members.some( linkCollection?.members.some(
(e) => e.userId === userId && e.canUpdate (e) => e.userId === userId && e.canUpdate
) ? ( ) ? (
<div <div
title="Edit" title="Edit"
onClick={() => { onClick={() => setEditLinkModal(true)}
link className={`btn btn-ghost btn-square btn-sm`}
? setModal({
modal: "LINK",
state: true,
active: link,
method: "UPDATE",
})
: undefined;
}}
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faPen} icon={faPen}
className="w-6 h-6 text-gray-500 dark:text-gray-300" className="w-4 h-4 text-neutral"
/> />
</div> </div>
) : undefined} ) : undefined}
<div <div
onClick={() => { onClick={() => setPreservedFormatsModal(true)}
link
? setModal({
modal: "LINK",
state: true,
active: link,
method: "FORMATS",
})
: undefined;
}}
title="Preserved Formats" title="Preserved Formats"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} className={`btn btn-ghost btn-square btn-sm`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faBoxesStacked} icon={faBoxesStacked}
className="w-6 h-6 text-gray-500 dark:text-gray-300" className="w-4 h-4 text-neutral"
/> />
</div> </div>
@ -168,18 +161,16 @@ export default function LinkLayout({ children }: Props) {
(e) => e.userId === userId && e.canDelete (e) => e.userId === userId && e.canDelete
) ? ( ) ? (
<div <div
onClick={() => { onClick={(e) => {
if (link?.id) { (document?.activeElement as HTMLElement)?.blur();
removeLink(link.id); e.shiftKey ? deleteLink() : setDeleteLinkModal(true);
router.back();
}
}} }}
title="Delete" title="Delete"
className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} className={`btn btn-ghost btn-square btn-sm`}
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faTrashCan} icon={faTrashCan}
className="w-6 h-6 text-gray-500 dark:text-gray-300" className="w-4 h-4 text-neutral"
/> />
</div> </div>
) : undefined} ) : undefined}
@ -189,7 +180,7 @@ export default function LinkLayout({ children }: Props) {
{children} {children}
{sidebar ? ( {sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30"> <div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler <ClickAwayHandler
className="h-full" className="h-full"
onClickOutside={toggleSidebar} onClickOutside={toggleSidebar}
@ -201,6 +192,24 @@ export default function LinkLayout({ children }: Props) {
</div> </div>
) : null} ) : null}
</div> </div>
{link && editLinkModal ? (
<EditLinkModal
onClose={() => setEditLinkModal(false)}
activeLink={link}
/>
) : undefined}
{link && deleteLinkModal ? (
<DeleteLinkModal
onClose={() => setDeleteLinkModal(false)}
activeLink={link}
/>
) : undefined}
{link && preservedFormatsModal ? (
<PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)}
activeLink={link}
/>
) : undefined}
</div> </div>
</> </>
); );

View File

@ -53,14 +53,14 @@ export default function SettingsLayout({ children }: Props) {
<div className="gap-2 inline-flex mr-3"> <div className="gap-2 inline-flex mr-3">
<div <div
onClick={toggleSidebar} onClick={toggleSidebar}
className="inline-flex lg:hidden gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700" className="text-neutral btn btn-square btn-sm btn-ghost lg:hidden"
> >
<FontAwesomeIcon icon={faBars} className="w-5 h-5" /> <FontAwesomeIcon icon={faBars} className="w-5 h-5" />
</div> </div>
<Link <Link
href="/dashboard" href="/dashboard"
className="inline-flex w-fit gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700" className="text-neutral btn btn-square btn-sm btn-ghost"
> >
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5" /> <FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5" />
</Link> </Link>
@ -69,7 +69,7 @@ export default function SettingsLayout({ children }: Props) {
{children} {children}
{sidebar ? ( {sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-gray-500 bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30"> <div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler <ClickAwayHandler
className="h-full" className="h-full"
onClickOutside={toggleSidebar} onClickOutside={toggleSidebar}

View File

@ -1,17 +1,20 @@
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import getTitle from "@/lib/api/getTitle"; import getTitle from "@/lib/shared/getTitle";
import archive from "@/lib/api/archive"; import urlHandler from "@/lib/api/urlHandler";
import { UsersAndCollections } from "@prisma/client"; import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
import pdfHandler from "../../pdfHandler";
import validateUrlSize from "../../validateUrlSize";
import imageHandler from "../../imageHandler";
export default async function postLink( export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags, link: LinkIncludingShortenedCollectionAndTags,
userId: number userId: number
) { ) {
try { try {
new URL(link.url); if (link.url) new URL(link.url);
} catch (error) { } catch (error) {
return { return {
response: response:
@ -45,14 +48,33 @@ export default async function postLink(
const description = const description =
link.description && link.description !== "" link.description && link.description !== ""
? link.description ? link.description
: await getTitle(link.url); : link.url
? await getTitle(link.url)
: undefined;
const validatedUrl = link.url ? await validateUrlSize(link.url) : undefined;
if (validatedUrl === null)
return { response: "File is too large to be stored.", status: 400 };
const contentType = validatedUrl?.get("content-type");
let linkType = "url";
let imageExtension = "png";
if (!link.url) linkType = link.type;
else if (contentType === "application/pdf") linkType = "pdf";
else if (contentType?.startsWith("image")) {
linkType = "image";
if (contentType === "image/jpeg") imageExtension = "jpeg";
else if (contentType === "image/png") imageExtension = "png";
}
const newLink = await prisma.link.create({ const newLink = await prisma.link.create({
data: { data: {
url: link.url, url: link.url,
name: link.name, name: link.name,
description, description,
readabilityPath: "pending", type: linkType,
collection: { collection: {
connectOrCreate: { connectOrCreate: {
where: { where: {
@ -91,7 +113,37 @@ export default async function postLink(
createFolder({ filePath: `archives/${newLink.collectionId}` }); createFolder({ filePath: `archives/${newLink.collectionId}` });
archive(newLink.id, newLink.url, userId); newLink.url && linkType === "url"
? urlHandler(newLink.id, newLink.url, userId)
: undefined;
newLink.url && linkType === "pdf"
? pdfHandler(newLink.id, newLink.url)
: undefined;
newLink.url && linkType === "image"
? imageHandler(newLink.id, newLink.url, imageExtension)
: undefined;
!newLink.url && linkType === "pdf"
? await prisma.link.update({
where: { id: newLink.id },
data: {
pdfPath: "pending",
lastPreserved: new Date().toISOString(),
},
})
: undefined;
!newLink.url && linkType === "image"
? await prisma.link.update({
where: { id: newLink.id },
data: {
screenshotPath: "pending",
lastPreserved: new Date().toISOString(),
},
})
: undefined;
return { response: newLink, status: 200 }; return { response: newLink, status: 200 };
} }

37
lib/api/imageHandler.ts Normal file
View File

@ -0,0 +1,37 @@
import { prisma } from "@/lib/api/db";
import createFile from "@/lib/api/storage/createFile";
import fs from "fs";
import path from "path";
export default async function imageHandler(
linkId: number,
url: string | null,
extension: string,
file?: string
) {
const pdf = await fetch(url as string).then((res) => res.blob());
const buffer = Buffer.from(await pdf.arrayBuffer());
const linkExists = await prisma.link.findUnique({
where: { id: linkId },
});
linkExists
? await createFile({
data: buffer,
filePath: `archives/${linkExists.collectionId}/${linkId}.${extension}`,
})
: undefined;
await prisma.link.update({
where: { id: linkId },
data: {
screenshotPath: linkExists
? `archives/${linkExists.collectionId}/${linkId}.${extension}`
: null,
pdfPath: null,
readabilityPath: null,
},
});
}

44
lib/api/pdfHandler.ts Normal file
View File

@ -0,0 +1,44 @@
import { prisma } from "@/lib/api/db";
import createFile from "@/lib/api/storage/createFile";
import fs from "fs";
import path from "path";
export default async function pdfHandler(
linkId: number,
url: string | null,
file?: string
) {
const targetLink = await prisma.link.update({
where: { id: linkId },
data: {
pdfPath: "pending",
lastPreserved: new Date().toISOString(),
},
});
const pdf = await fetch(url as string).then((res) => res.blob());
const buffer = Buffer.from(await pdf.arrayBuffer());
const linkExists = await prisma.link.findUnique({
where: { id: linkId },
});
linkExists
? await createFile({
data: buffer,
filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`,
})
: undefined;
await prisma.link.update({
where: { id: linkId },
data: {
pdfPath: linkExists
? `archives/${linkExists.collectionId}/${linkId}.pdf`
: null,
readabilityPath: null,
screenshotPath: null,
},
});
}

View File

@ -97,7 +97,7 @@ export default async function readFile(filePath: string) {
return { return {
file: "File not found.", file: "File not found.",
contentType: "text/plain", contentType: "text/plain",
status: 400, status: 404,
}; };
else { else {
const file = fs.readFileSync(creationPath); const file = fs.readFileSync(creationPath);

View File

@ -6,7 +6,7 @@ import { Readability } from "@mozilla/readability";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
export default async function archive( export default async function urlHandler(
linkId: number, linkId: number,
url: string, url: string,
userId: number userId: number
@ -37,6 +37,23 @@ export default async function archive(
const content = await page.content(); const content = await page.content();
// TODO
// const session = await page.context().newCDPSession(page);
// const doc = await session.send("Page.captureSnapshot", {
// format: "mhtml",
// });
// const saveDocLocally = (doc: any) => {
// console.log(doc);
// return createFile({
// data: doc,
// filePath: `archives/${targetLink.collectionId}/${linkId}.mhtml`,
// });
// };
// saveDocLocally(doc.data);
// Readability // Readability
const window = new JSDOM("").window; const window = new JSDOM("").window;

View File

@ -0,0 +1,13 @@
export default async function validateUrlSize(url: string) {
try {
const response = await fetch(url, { method: "HEAD" });
const totalSizeMB =
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
if (totalSizeMB > 50) return null;
else return response.headers;
} catch (err) {
console.log(err);
return null;
}
}

View File

@ -1,4 +1,4 @@
export default function htmlDecode(input: string) { export default function unescapeString(input: string) {
var doc = new DOMParser().parseFromString(input, "text/html"); var doc = new DOMParser().parseFromString(input, "text/html");
return doc.documentElement.textContent; return doc.documentElement.textContent;
} }

View File

@ -25,6 +25,7 @@
"@prisma/client": "^4.16.2", "@prisma/client": "^4.16.2",
"@stripe/stripe-js": "^1.54.1", "@stripe/stripe-js": "^1.54.1",
"@types/crypto-js": "^4.1.1", "@types/crypto-js": "^4.1.1",
"@types/formidable": "^3.4.5",
"@types/node": "20.4.4", "@types/node": "20.4.4",
"@types/nodemailer": "^6.4.8", "@types/nodemailer": "^6.4.8",
"@types/react": "18.2.14", "@types/react": "18.2.14",
@ -37,13 +38,13 @@
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"eslint": "8.46.0", "eslint": "8.46.0",
"eslint-config-next": "13.4.9", "eslint-config-next": "13.4.9",
"formidable": "^3.5.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"lottie-web": "^5.12.2", "lottie-web": "^5.12.2",
"micro": "^10.0.1", "micro": "^10.0.1",
"next": "13.4.12", "next": "13.4.12",
"next-auth": "^4.22.1", "next-auth": "^4.22.1",
"next-themes": "^0.2.1",
"nodemailer": "^6.9.3", "nodemailer": "^6.9.3",
"playwright": "^1.35.1", "playwright": "^1.35.1",
"react": "18.2.0", "react": "18.2.0",

View File

@ -1,4 +1,4 @@
import React, { useEffect } from "react"; import React from "react";
import "@/styles/globals.css"; import "@/styles/globals.css";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import type { AppProps } from "next/app"; import type { AppProps } from "next/app";
@ -6,7 +6,6 @@ import Head from "next/head";
import AuthRedirect from "@/layouts/AuthRedirect"; import AuthRedirect from "@/layouts/AuthRedirect";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { ThemeProvider } from "next-themes";
export default function App({ export default function App({
Component, Component,
@ -14,13 +13,6 @@ export default function App({
}: AppProps<{ }: AppProps<{
session: Session; session: Session;
}>) { }>) {
const defaultTheme: "light" | "dark" = "dark";
useEffect(() => {
if (!localStorage.getItem("theme"))
localStorage.setItem("theme", defaultTheme);
}, []);
return ( return (
<SessionProvider <SessionProvider
session={pageProps.session} session={pageProps.session}
@ -50,17 +42,15 @@ export default function App({
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
</Head> </Head>
<AuthRedirect> <AuthRedirect>
<ThemeProvider attribute="class"> <Toaster
<Toaster position="top-center"
position="top-center" reverseOrder={false}
reverseOrder={false} toastOptions={{
toastOptions={{ className:
className: "border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white", }}
}} />
/> <Component {...pageProps} />
<Component {...pageProps} />
</ThemeProvider>
</AuthRedirect> </AuthRedirect>
</SessionProvider> </SessionProvider>
); );

View File

@ -3,47 +3,142 @@ import readFile from "@/lib/api/storage/readFile";
import { getToken } from "next-auth/jwt"; import { getToken } from "next-auth/jwt";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import { ArchivedFormat } from "@/types/global"; import { ArchivedFormat } from "@/types/global";
import verifyUser from "@/lib/api/verifyUser";
import getPermission from "@/lib/api/getPermission";
import { UsersAndCollections } from "@prisma/client";
import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile";
import fs from "fs";
export const config = {
api: {
bodyParser: false,
},
};
export default async function Index(req: NextApiRequest, res: NextApiResponse) { export default async function Index(req: NextApiRequest, res: NextApiResponse) {
const linkId = Number(req.query.linkId); const linkId = Number(req.query.linkId);
const format = Number(req.query.format); const format = Number(req.query.format);
let suffix; let suffix: string;
if (format === ArchivedFormat.screenshot) suffix = ".png"; if (format === ArchivedFormat.png) suffix = ".png";
else if (format === ArchivedFormat.jpeg) suffix = ".jpeg";
else if (format === ArchivedFormat.pdf) suffix = ".pdf"; else if (format === ArchivedFormat.pdf) suffix = ".pdf";
else if (format === ArchivedFormat.readability) suffix = "_readability.json"; else if (format === ArchivedFormat.readability) suffix = "_readability.json";
//@ts-ignore
if (!linkId || !suffix) if (!linkId || !suffix)
return res.status(401).json({ response: "Invalid parameters." }); return res.status(401).json({ response: "Invalid parameters." });
const token = await getToken({ req }); if (req.method === "GET") {
const userId = token?.id; const token = await getToken({ req });
const userId = token?.id;
const collectionIsAccessible = await prisma.collection.findFirst({ const collectionIsAccessible = await prisma.collection.findFirst({
where: { where: {
links: { links: {
some: { some: {
id: linkId, id: linkId,
},
}, },
OR: [
{ ownerId: userId || -1 },
{ members: { some: { userId: userId || -1 } } },
{ isPublic: true },
],
}, },
OR: [ });
{ ownerId: userId || -1 },
{ members: { some: { userId: userId || -1 } } },
{ isPublic: true },
],
},
});
if (!collectionIsAccessible) if (!collectionIsAccessible)
return res return res
.status(401) .status(401)
.json({ response: "You don't have access to this collection." }); .json({ response: "You don't have access to this collection." });
const { file, contentType, status } = await readFile( const { file, contentType, status } = await readFile(
`archives/${collectionIsAccessible.id}/${linkId + suffix}` `archives/${collectionIsAccessible.id}/${linkId + suffix}`
); );
res.setHeader("Content-Type", contentType).status(status as number);
return res.send(file); res.setHeader("Content-Type", contentType).status(status as number);
return res.send(file);
}
// else if (req.method === "POST") {
// const user = await verifyUser({ req, res });
// if (!user) return;
// const collectionPermissions = await getPermission({
// userId: user.id,
// linkId,
// });
// const memberHasAccess = collectionPermissions?.members.some(
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate
// );
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
// return { response: "Collection is not accessible.", status: 401 };
// // await uploadHandler(linkId, )
// const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_UPLOAD_SIZE);
// const form = formidable({
// maxFields: 1,
// maxFiles: 1,
// maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
// });
// form.parse(req, async (err, fields, files) => {
// const allowedMIMETypes = [
// "application/pdf",
// "image/png",
// "image/jpg",
// "image/jpeg",
// ];
// if (
// err ||
// !files.file ||
// !files.file[0] ||
// !allowedMIMETypes.includes(files.file[0].mimetype || "")
// ) {
// // Handle parsing error
// return res.status(500).json({
// response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
// });
// } else {
// const fileBuffer = fs.readFileSync(files.file[0].filepath);
// const linkStillExists = await prisma.link.findUnique({
// where: { id: linkId },
// });
// if (linkStillExists) {
// await createFile({
// filePath: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// data: fileBuffer,
// });
// await prisma.link.update({
// where: { id: linkId },
// data: {
// screenshotPath: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// lastPreserved: new Date().toISOString(),
// },
// });
// }
// fs.unlinkSync(files.file[0].filepath);
// }
// return res.status(200).json({
// response: files,
// });
// });
// }
} }

View File

@ -1,7 +1,8 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import archive from "@/lib/api/archive"; import urlHandler from "@/lib/api/urlHandler";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@ -41,7 +42,13 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
} minutes or create a new one.`, } minutes or create a new one.`,
}); });
archive(link.id, link.url, user.id); if (link.url && isValidUrl(link.url)) {
urlHandler(link.id, link.url, user.id);
return res.status(200).json({
response: "Link is not a webpage to be archived.",
});
}
return res.status(200).json({ return res.status(200).json({
response: "Link is being archived.", response: "Link is being archived.",
}); });

View File

@ -41,28 +41,26 @@ export default function ChooseUsername() {
return ( return (
<CenteredForm> <CenteredForm>
<form onSubmit={submitUsername}> <form onSubmit={submitUsername}>
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:border-neutral-700 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
<p className="text-3xl text-center text-black dark:text-white font-extralight"> <p className="text-3xl text-center font-extralight">
Choose a Username Choose a Username
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-0"></div>
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">Username</p>
Username
</p>
<TextInput <TextInput
autoFocus autoFocus
placeholder="john" placeholder="john"
value={inputedUsername} value={inputedUsername}
className="bg-white" className="bg-base-100"
onChange={(e) => setInputedUsername(e.target.value)} onChange={(e) => setInputedUsername(e.target.value)}
/> />
</div> </div>
<div> <div>
<p className="text-md text-gray-500 dark:text-gray-400 mt-1"> <p className="text-md text-neutral mt-1">
Feel free to reach out to us at{" "} Feel free to reach out to us at{" "}
<a <a
className="font-semibold underline" className="font-semibold underline"
@ -83,7 +81,7 @@ export default function ChooseUsername() {
<div <div
onClick={() => signOut()} onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-gray-500 dark:text-gray-400 font-semibold " className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
> >
Sign Out Sign Out
</div> </div>

View File

@ -1,37 +1,32 @@
import Dropdown from "@/components/Dropdown";
import LinkCard from "@/components/LinkCard"; import LinkCard from "@/components/LinkCard";
import useCollectionStore from "@/store/collections"; import useCollectionStore from "@/store/collections";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { CollectionIncludingMembersAndLinkCount, Sort } from "@/types/global"; import { CollectionIncludingMembersAndLinkCount, Sort } from "@/types/global";
import { import { faEllipsis, faFolder } from "@fortawesome/free-solid-svg-icons";
faEllipsis,
faFolder,
faSort,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
import useModalStore from "@/store/modals";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import NoLinksFound from "@/components/NoLinksFound"; import NoLinksFound from "@/components/NoLinksFound";
import { useTheme } from "next-themes"; import useLocalSettingsStore from "@/store/localSettings";
import useAccountStore from "@/store/account";
import getPublicUserData from "@/lib/client/getPublicUserData";
import EditCollectionModal from "@/components/ModalContent/EditCollectionModal";
import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal";
import DeleteCollectionModal from "@/components/ModalContent/DeleteCollectionModal";
export default function Index() { export default function Index() {
const { setModal } = useModalStore(); const { settings } = useLocalSettingsStore();
const router = useRouter(); const router = useRouter();
const { theme } = useTheme();
const { links } = useLinkStore(); const { links } = useLinkStore();
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const [expandDropdown, setExpandDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [activeCollection, setActiveCollection] = const [activeCollection, setActiveCollection] =
@ -47,185 +42,184 @@ export default function Index() {
); );
}, [router, collections]); }, [router, collections]);
const { account } = useAccountStore();
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
});
useEffect(() => {
const fetchOwner = async () => {
if (activeCollection && activeCollection.ownerId !== account.id) {
const owner = await getPublicUserData(
activeCollection.ownerId as number
);
setCollectionOwner(owner);
} else if (activeCollection && activeCollection.ownerId === account.id) {
setCollectionOwner({
id: account.id as number,
name: account.name,
username: account.username as string,
image: account.image as string,
});
}
};
fetchOwner();
}, [activeCollection]);
const [editCollectionModal, setEditCollectionModal] = useState(false);
const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false);
const [deleteCollectionModal, setDeleteCollectionModal] = useState(false);
return ( return (
<MainLayout> <MainLayout>
<div className="p-5 flex flex-col gap-5 w-full h-full"> <div
<div style={{
style={{ backgroundImage: `linear-gradient(${activeCollection?.color}20 10%, ${
backgroundImage: `linear-gradient(-45deg, ${ settings.theme === "dark" ? "#262626" : "#f3f4f6"
activeCollection?.color } 14rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}30 10%, ${theme === "dark" ? "#262626" : "#f3f4f6"} 50%, ${ }}
theme === "dark" ? "#262626" : "#f9fafb" className="h-full p-5 flex gap-3 flex-col"
} 100%)`, >
}} <div className="flex flex-col sm:flex-row gap-3 justify-between sm:items-start">
className="border border-solid border-sky-100 dark:border-neutral-700 rounded-2xl shadow min-h-[10rem] p-5 flex gap-5 flex-col justify-between" {activeCollection && (
> <div className="flex gap-3 items-center">
<div className="flex flex-col sm:flex-row gap-3 justify-between sm:items-start"> <div className="flex gap-2">
{activeCollection && ( <FontAwesomeIcon
<div className="flex gap-3 items-center"> icon={faFolder}
<div className="flex gap-2"> style={{ color: activeCollection?.color }}
<FontAwesomeIcon className="sm:w-8 sm:h-8 w-6 h-6 mt-3 drop-shadow"
icon={faFolder} />
style={{ color: activeCollection?.color }} <p className="sm:text-4xl text-3xl capitalize w-full py-1 break-words hyphens-auto font-thin">
className="sm:w-8 sm:h-8 w-6 h-6 mt-3 drop-shadow" {activeCollection?.name}
/> </p>
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white w-full py-1 break-words hyphens-auto font-thin">
{activeCollection?.name}
</p>
</div>
</div> </div>
)} </div>
)}
</div>
{activeCollection ? ( {activeCollection ? (
<div className={`min-w-[15rem]`}>
<div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
<div <div
className={`min-w-[15rem] ${ className="flex items-center btn px-2 btn-ghost rounded-full w-fit"
activeCollection.members[1] && "mr-3" onClick={() => setEditCollectionSharingModal(true)}
}`}
> >
<div {collectionOwner.id ? (
onClick={() => <ProfilePhoto
setModal({ src={collectionOwner.image || undefined}
modal: "COLLECTION", name={collectionOwner.name}
state: true, />
method: "UPDATE", ) : undefined}
isOwner: permissions === true, {activeCollection.members
active: activeCollection, .sort((a, b) => (a.userId as number) - (b.userId as number))
defaultIndex: permissions === true ? 1 : 0, .map((e, i) => {
}) return (
} <ProfilePhoto
className="hover:opacity-80 duration-100 flex justify-center sm:justify-end items-center w-fit sm:mr-0 sm:ml-auto cursor-pointer" key={i}
> src={e.user.image ? e.user.image : undefined}
{activeCollection?.members className="-ml-3"
.sort((a, b) => (a.userId as number) - (b.userId as number)) name={e.user.name}
.map((e, i) => { />
return ( );
<ProfilePhoto })
key={i} .slice(0, 3)}
src={e.user.image ? e.user.image : undefined} {activeCollection.members.length - 3 > 0 ? (
className={`${ <div className={`avatar drop-shadow-md placeholder -ml-3`}>
activeCollection.members[1] && "-mr-3" <div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
} border-[3px]`} <span>+{activeCollection.members.length - 3}</span>
/>
);
})
.slice(0, 4)}
{activeCollection?.members.length &&
activeCollection.members.length - 4 > 0 ? (
<div className="h-10 w-10 text-white flex items-center justify-center rounded-full border-[3px] bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700 -mr-3">
+{activeCollection?.members?.length - 4}
</div> </div>
) : null} </div>
</div>
</div>
) : null}
</div>
<div className="text-black dark:text-white flex justify-between items-end gap-5">
<p>{activeCollection?.description}</p>
<div className="flex items-center gap-2">
<div className="relative">
<div
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-black hover:dark:bg-white hover:bg-opacity-10 hover:dark:bg-opacity-10 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null} ) : null}
</div> </div>
<div className="relative"> <p className="text-neutral text-sm font-semibold">
By {collectionOwner.name}
{activeCollection.members.length > 0
? ` and ${activeCollection.members.length} others`
: undefined}
.
</p>
</div>
</div>
) : undefined}
{activeCollection?.description ? (
<p>{activeCollection?.description}</p>
) : undefined}
<div className="divider my-0"></div>
<div className="flex justify-between items-end gap-5">
<p>Showing {activeCollection?._count?.links} results</p>
<div className="flex items-center gap-2">
<SortDropdown sortBy={sortBy} setSort={setSortBy} />
<div className="relative">
<div className="dropdown dropdown-bottom dropdown-end">
<div <div
onClick={() => setExpandDropdown(!expandDropdown)} tabIndex={0}
id="expand-dropdown" role="button"
className="inline-flex rounded-md cursor-pointer hover:bg-black hover:dark:bg-white hover:bg-opacity-10 hover:dark:bg-opacity-10 duration-100 p-1" className="btn btn-ghost btn-sm btn-square text-neutral"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faEllipsis} icon={faEllipsis}
id="expand-dropdown"
title="More" title="More"
className="w-5 h-5 text-gray-500 dark:text-gray-300" className="w-5 h-5"
/> />
</div> </div>
{expandDropdown ? ( <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-52 mt-1">
<Dropdown {permissions === true ? (
items={[ <li>
permissions === true <div
? { role="button"
name: "Edit Collection Info", tabIndex={0}
onClick: () => { onClick={() => {
activeCollection && (document?.activeElement as HTMLElement)?.blur();
setModal({ setEditCollectionModal(true);
modal: "COLLECTION", }}
state: true, >
method: "UPDATE", Edit Collection Info
isOwner: permissions === true, </div>
active: activeCollection, </li>
}); ) : undefined}
setExpandDropdown(false); <li>
}, <div
} role="button"
: undefined, tabIndex={0}
{ onClick={() => {
name: (document?.activeElement as HTMLElement)?.blur();
permissions === true setEditCollectionSharingModal(true);
? "Share/Collaborate" }}
: "View Team", >
onClick: () => { {permissions === true
activeCollection && ? "Share and Collaborate"
setModal({ : "View Team"}
modal: "COLLECTION", </div>
state: true, </li>
method: "UPDATE", <li>
isOwner: permissions === true, <div
active: activeCollection, role="button"
defaultIndex: permissions === true ? 1 : 0, tabIndex={0}
}); onClick={() => {
setExpandDropdown(false); (document?.activeElement as HTMLElement)?.blur();
}, setDeleteCollectionModal(true);
}, }}
>
{ {permissions === true
name: ? "Delete Collection"
permissions === true : "Leave Collection"}
? "Delete Collection" </div>
: "Leave Collection", </li>
onClick: () => { </ul>
activeCollection &&
setModal({
modal: "COLLECTION",
state: true,
method: "UPDATE",
isOwner: permissions === true,
active: activeCollection,
defaultIndex: permissions === true ? 2 : 1,
});
setExpandDropdown(false);
},
},
]}
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "expand-dropdown")
setExpandDropdown(false);
}}
className="absolute top-8 right-0 z-10 w-44"
/>
) : null}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{links.some((e) => e.collectionId === Number(router.query.id)) ? ( {links.some((e) => e.collectionId === Number(router.query.id)) ? (
<div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5"> <div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5">
{links {links
@ -238,6 +232,28 @@ export default function Index() {
<NoLinksFound /> <NoLinksFound />
)} )}
</div> </div>
{activeCollection ? (
<>
{editCollectionModal ? (
<EditCollectionModal
onClose={() => setEditCollectionModal(false)}
activeCollection={activeCollection}
/>
) : undefined}
{editCollectionSharingModal ? (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={activeCollection}
/>
) : undefined}
{deleteCollectionModal ? (
<DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)}
activeCollection={activeCollection}
/>
) : undefined}
</>
) : undefined}
</MainLayout> </MainLayout>
); );
} }

View File

@ -3,32 +3,29 @@ import {
faEllipsis, faEllipsis,
faFolder, faFolder,
faPlus, faPlus,
faSort,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import CollectionCard from "@/components/CollectionCard"; import CollectionCard from "@/components/CollectionCard";
import Dropdown from "@/components/Dropdown";
import { useState } from "react"; import { useState } from "react";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useModalStore from "@/store/modals";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import useSort from "@/hooks/useSort"; import useSort from "@/hooks/useSort";
import NewCollectionModal from "@/components/ModalContent/NewCollectionModal";
export default function Collections() { export default function Collections() {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const [expandDropdown, setExpandDropdown] = useState(false); const [expandDropdown, setExpandDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = 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);
const { data } = useSession(); const { data } = useSession();
const { setModal } = useModalStore();
useSort({ sortBy, setData: setSortedCollections, data: collections }); useSort({ sortBy, setData: setSortedCollections, data: collections });
const [newCollectionModal, setNewCollectionModal] = useState(false);
return ( return (
<MainLayout> <MainLayout>
<div className="p-5"> <div className="p-5">
@ -37,77 +34,20 @@ export default function Collections() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
className="sm:w-10 sm:h-10 w-6 h-6 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-10 sm:h-10 w-6 h-6 text-primary drop-shadow"
/> />
<div> <div>
<p className="text-3xl capitalize text-black dark:text-white font-thin"> <p className="text-3xl capitalize font-thin">
Your Collections Your Collections
</p> </p>
<p className="text-black dark:text-white"> <p>Collections you own</p>
Collections you own
</p>
</div> </div>
</div> </div>
<div className="relative mt-2">
<div
onClick={() => setExpandDropdown(!expandDropdown)}
id="expand-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faEllipsis}
id="expand-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{expandDropdown ? (
<Dropdown
items={[
{
name: "New Collection",
onClick: () => {
setModal({
modal: "COLLECTION",
state: true,
method: "CREATE",
});
setExpandDropdown(false);
},
},
]}
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "expand-dropdown")
setExpandDropdown(false);
}}
className="absolute top-8 sm:left-0 right-0 sm:right-auto w-36"
/>
) : null}
</div>
</div> </div>
<div className="relative mt-2"> <div className="relative mt-2">
<div <SortDropdown sortBy={sortBy} setSort={setSortBy} />
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div> </div>
</div> </div>
@ -119,21 +59,13 @@ export default function Collections() {
})} })}
<div <div
className="p-5 bg-gray-50 dark:bg-neutral-800 self-stretch border border-solid border-sky-100 dark:border-neutral-700 min-h-[12rem] rounded-2xl cursor-pointer shadow duration-100 hover:shadow-none flex flex-col gap-4 justify-center items-center group" className="card card-compact shadow-md hover:shadow-none duration-200 border border-neutral-content p-5 bg-base-200 self-stretch min-h-[12rem] rounded-2xl cursor-pointer flex flex-col gap-4 justify-center items-center group btn"
onClick={() => { onClick={() => setNewCollectionModal(true)}
setModal({
modal: "COLLECTION",
state: true,
method: "CREATE",
});
}}
> >
<p className="text-black dark:text-white group-hover:opacity-0 duration-100"> <p className="group-hover:opacity-0 duration-100">New Collection</p>
New Collection
</p>
<FontAwesomeIcon <FontAwesomeIcon
icon={faPlus} icon={faPlus}
className="w-8 h-8 text-sky-500 dark:text-sky-500 group-hover:w-12 group-hover:h-12 group-hover:-mt-10 duration-100" className="w-8 h-8 text-primary group-hover:w-12 group-hover:h-12 group-hover:-mt-10 duration-100"
/> />
</div> </div>
</div> </div>
@ -143,16 +75,14 @@ export default function Collections() {
<div className="flex items-center gap-3 my-5"> <div className="flex items-center gap-3 my-5">
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
className="sm:w-10 sm:h-10 w-6 h-6 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-10 sm:h-10 w-6 h-6 text-primary drop-shadow"
/> />
<div> <div>
<p className="text-3xl capitalize text-black dark:text-white font-thin"> <p className="text-3xl capitalize font-thin">
Other Collections Other Collections
</p> </p>
<p className="text-black dark:text-white"> <p>Shared collections you&apos;re a member of</p>
Shared collections you&apos;re a member of
</p>
</div> </div>
</div> </div>
@ -166,6 +96,9 @@ export default function Collections() {
</> </>
) : undefined} ) : undefined}
</div> </div>
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined}
</MainLayout> </MainLayout>
); );
} }

View File

@ -5,12 +5,12 @@ import React from "react";
export default function EmailConfirmaion() { export default function EmailConfirmaion() {
return ( return (
<CenteredForm> <CenteredForm>
<div className="p-4 max-w-[30rem] min-w-80 w-full rounded-2xl shadow-md mx-auto border border-sky-100 dark:border-neutral-700 bg-slate-50 text-black dark:text-white dark:bg-neutral-800"> <div className="p-4 max-w-[30rem] min-w-80 w-full rounded-2xl shadow-md mx-auto border border-neutral-content bg-base-200">
<p className="text-center text-2xl sm:text-3xl font-extralight mb-2 "> <p className="text-center text-2xl sm:text-3xl font-extralight mb-2 ">
Please check your Email Please check your Email
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700 my-3" /> <div className="divider my-3"></div>
<p>A sign in link has been sent to your email address.</p> <p>A sign in link has been sent to your email address.</p>

View File

@ -23,8 +23,8 @@ import React from "react";
import useModalStore from "@/store/modals"; import useModalStore from "@/store/modals";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { MigrationFormat, MigrationRequest } from "@/types/global"; import { MigrationFormat, MigrationRequest } from "@/types/global";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import DashboardItem from "@/components/DashboardItem"; import DashboardItem from "@/components/DashboardItem";
import NewLinkModal from "@/components/ModalContent/NewLinkModal";
export default function Dashboard() { export default function Dashboard() {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
@ -63,8 +63,6 @@ export default function Dashboard() {
handleNumberOfLinksToShow(); handleNumberOfLinksToShow();
}, [width]); }, [width]);
const [importDropdown, setImportDropdown] = useState(false);
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];
@ -92,8 +90,6 @@ export default function Dashboard() {
toast.success("Imported the Bookmarks! Reloading the page..."); toast.success("Imported the Bookmarks! Reloading the page...");
setImportDropdown(false);
setTimeout(() => { setTimeout(() => {
location.reload(); location.reload();
}, 2000); }, 2000);
@ -104,35 +100,32 @@ export default function Dashboard() {
} }
}; };
const [newLinkModal, setNewLinkModal] = useState(false);
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 gap-3"> <div className="flex items-center gap-3">
<FontAwesomeIcon <FontAwesomeIcon
icon={faChartSimple} icon={faChartSimple}
className="sm:w-10 sm:h-10 w-6 h-6 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-10 sm:h-10 w-6 h-6 text-primary drop-shadow"
/> />
<div> <div>
<p className="text-3xl capitalize text-black dark:text-white font-thin"> <p className="text-3xl capitalize font-thin">Dashboard</p>
Dashboard
</p>
<p className="text-black dark:text-white"> <p>A brief overview of your data</p>
A brief overview of your data
</p>
</div> </div>
</div> </div>
<div> <div>
<div className="flex justify-evenly flex-col md:flex-row md:items-center gap-2 md:w-full h-full rounded-2xl p-8 border border-sky-100 dark:border-neutral-700 bg-gray-100 dark:bg-neutral-800"> <div className="flex justify-evenly flex-col md:flex-row md:items-center gap-2 md:w-full h-full rounded-2xl p-8 border border-neutral-content bg-base-200">
<DashboardItem <DashboardItem
name={numberOfLinks === 1 ? "Link" : "Links"} name={numberOfLinks === 1 ? "Link" : "Links"}
value={numberOfLinks} value={numberOfLinks}
icon={faLink} icon={faLink}
/> />
<hr className="border-sky-100 dark:border-neutral-700 md:hidden my-5" /> <div className="divider md:divider-horizontal"></div>
<div className="h-24 border-1 border-l border-sky-100 dark:border-neutral-700 hidden md:block"></div>
<DashboardItem <DashboardItem
name={collections.length === 1 ? "Collection" : "Collections"} name={collections.length === 1 ? "Collection" : "Collections"}
@ -140,8 +133,7 @@ export default function Dashboard() {
icon={faFolder} icon={faFolder}
/> />
<hr className="border-sky-100 dark:border-neutral-700 md:hidden my-5" /> <div className="divider md:divider-horizontal"></div>
<div className="h-24 border-1 border-r border-sky-100 dark:border-neutral-700 hidden md:block"></div>
<DashboardItem <DashboardItem
name={tags.length === 1 ? "Tag" : "Tags"} name={tags.length === 1 ? "Tag" : "Tags"}
@ -155,21 +147,16 @@ export default function Dashboard() {
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<FontAwesomeIcon <FontAwesomeIcon
icon={faClockRotateLeft} icon={faClockRotateLeft}
className="w-5 h-5 text-sky-500 dark:text-sky-500 drop-shadow" className="w-5 h-5 text-primary drop-shadow"
/> />
<p className="text-2xl text-black dark:text-white"> <p className="text-2xl">Recently Added Links</p>
Recently Added Links
</p>
</div> </div>
<Link <Link
href="/links" href="/links"
className="text-black dark:text-white flex items-center gap-2 cursor-pointer" className="flex items-center gap-2 cursor-pointer"
> >
View All View All
<FontAwesomeIcon <FontAwesomeIcon icon={faChevronRight} className={`w-4 h-4`} />
icon={faChevronRight}
className={`w-4 h-4 text-black dark:text-white`}
/>
</Link> </Link>
</div> </div>
@ -180,7 +167,7 @@ export default function Dashboard() {
{links[0] ? ( {links[0] ? (
<div className="w-full"> <div className="w-full">
<div <div
className={`grid overflow-hidden 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full`} className={`grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full`}
> >
{links.slice(0, showLinks).map((e, i) => ( {links.slice(0, showLinks).map((e, i) => (
<LinkCard key={i} link={e} count={i} /> <LinkCard key={i} link={e} count={i} />
@ -190,101 +177,87 @@ export default function Dashboard() {
) : ( ) : (
<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-sky-100 dark:border-neutral-700 w-full mx-auto p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800" 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 text-black dark:text-white"> <p className="text-center text-2xl">
View Your Recently Added Links Here! View Your Recently Added Links Here!
</p> </p>
<p className="text-center mx-auto max-w-96 w-fit text-gray-500 dark:text-gray-300 text-sm mt-2"> <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 This section will view your latest added Links across every
Collections you have access to. Collections you have access to.
</p> </p>
<div className="text-center text-black dark:text-white w-full mt-4 flex flex-wrap gap-4 justify-center"> <div className="text-center w-full mt-4 flex flex-wrap gap-4 justify-center">
<div <div
onClick={() => { onClick={() => {
setModal({ setNewLinkModal(true);
modal: "LINK",
state: true,
method: "CREATE",
});
}} }}
className="inline-flex gap-1 relative w-[11.4rem] items-center font-semibold select-none cursor-pointer p-2 px-3 rounded-md dark:hover:bg-sky-600 text-white bg-sky-700 hover:bg-sky-600 duration-100 group" className="inline-flex gap-1 relative w-[11rem] items-center btn btn-accent text-white group"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faPlus} icon={faPlus}
className="w-5 h-5 group-hover:ml-[4.325rem] absolute duration-100" className="w-5 h-5 left-4 group-hover:ml-[4rem] absolute duration-100"
/> />
<span className="group-hover:opacity-0 text-right w-full duration-100"> <span className="group-hover:opacity-0 text-right w-full duration-100">
Create New Link Create New Link
</span> </span>
</div> </div>
<div className="relative"> <div className="dropdown dropdown-bottom">
<div <div
onClick={() => setImportDropdown(!importDropdown)} tabIndex={0}
role="button"
className="flex gap-2 text-sm btn btn-outline btn-neutral group"
id="import-dropdown" id="import-dropdown"
className="flex gap-2 select-none text-sm cursor-pointer p-2 px-3 rounded-md border dark:hover:border-sky-600 text-black border-black dark:text-white dark:border-white hover:border-sky-500 hover:dark:border-sky-500 hover:text-sky-500 hover:dark:text-sky-500 duration-100 group"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faFileImport} icon={faFileImport}
className="w-5 h-5 duration-100" className="w-5 h-5 duration-100"
id="import-dropdown"
/> />
<span <p>Import From</p>
className="text-right w-full duration-100"
id="import-dropdown"
>
Import Your Bookmarks
</span>
</div> </div>
{importDropdown ? ( <ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60">
<ClickAwayHandler <li>
onClickOutside={(e: Event) => { <label
const target = e.target as HTMLInputElement; tabIndex={0}
if (target.id !== "import-dropdown") role="button"
setImportDropdown(false); htmlFor="import-linkwarden-file"
}} title="JSON File"
className={`absolute text-black dark:text-white top-10 left-0 w-52 py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`} >
> From Linkwarden
<div className="cursor-pointer rounded-md"> <input
<label type="file"
htmlFor="import-linkwarden-file" name="photo"
title="JSON File" id="import-linkwarden-file"
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer" accept=".json"
> className="hidden"
Linkwarden File... onChange={(e) =>
<input importBookmarks(e, MigrationFormat.linkwarden)
type="file" }
name="photo" />
id="import-linkwarden-file" </label>
accept=".json" </li>
className="hidden" <li>
onChange={(e) => <label
importBookmarks(e, MigrationFormat.linkwarden) tabIndex={0}
} role="button"
/> htmlFor="import-html-file"
</label> title="HTML File"
<label >
htmlFor="import-html-file" From Bookmarks HTML file
title="HTML File" <input
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer" type="file"
> name="photo"
Bookmarks HTML file... id="import-html-file"
<input accept=".html"
type="file" className="hidden"
name="photo" onChange={(e) =>
id="import-html-file" importBookmarks(e, MigrationFormat.htmlFile)
accept=".html" }
className="hidden" />
onChange={(e) => </label>
importBookmarks(e, MigrationFormat.htmlFile) </li>
} </ul>
/>
</label>
</div>
</ClickAwayHandler>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -295,19 +268,16 @@ export default function Dashboard() {
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<FontAwesomeIcon <FontAwesomeIcon
icon={faThumbTack} icon={faThumbTack}
className="w-5 h-5 text-sky-500 dark:text-sky-500 drop-shadow" className="w-5 h-5 text-primary drop-shadow"
/> />
<p className="text-2xl text-black dark:text-white">Pinned Links</p> <p className="text-2xl">Pinned Links</p>
</div> </div>
<Link <Link
href="/links/pinned" href="/links/pinned"
className="text-black dark:text-white flex items-center gap-2 cursor-pointer" className="flex items-center gap-2 cursor-pointer"
> >
View All View All
<FontAwesomeIcon <FontAwesomeIcon icon={faChevronRight} className={`w-4 h-4`} />
icon={faChevronRight}
className={`w-4 h-4 text-black dark:text-white`}
/>
</Link> </Link>
</div> </div>
@ -318,10 +288,9 @@ export default function Dashboard() {
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full"> <div className="w-full">
<div <div
className={`grid overflow-hidden 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full`} className={`grid 2xl:grid-cols-3 xl:grid-cols-2 grid-cols-1 gap-5 w-full`}
> >
{links {links
.filter((e) => e.pinnedBy && e.pinnedBy[0]) .filter((e) => e.pinnedBy && e.pinnedBy[0])
.map((e, i) => <LinkCard key={i} link={e} count={i} />) .map((e, i) => <LinkCard key={i} link={e} count={i} />)
.slice(0, showLinks)} .slice(0, showLinks)}
@ -330,12 +299,12 @@ export default function Dashboard() {
) : ( ) : (
<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-sky-100 dark:border-neutral-700 w-full mx-auto p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800" 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 text-black dark:text-white"> <p className="text-center text-2xl">
Pin Your Favorite Links Here! Pin Your Favorite Links Here!
</p> </p>
<p className="text-center mx-auto max-w-96 w-fit text-gray-500 dark:text-gray-300 text-sm mt-2"> <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 You can Pin your favorite Links by clicking on the three dots on
each Link and clicking{" "} each Link and clicking{" "}
<span className="font-semibold">Pin to Dashboard</span>. <span className="font-semibold">Pin to Dashboard</span>.
@ -344,6 +313,9 @@ export default function Dashboard() {
)} )}
</div> </div>
</div> </div>
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
</MainLayout> </MainLayout>
); );
} }

View File

@ -43,34 +43,32 @@ export default function Forgot() {
return ( return (
<CenteredForm> <CenteredForm>
<form onSubmit={sendConfirmation}> <form onSubmit={sendConfirmation}>
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:border-neutral-700 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
<p className="text-3xl text-center text-black dark:text-white font-extralight"> <p className="text-3xl text-center font-extralight">
Password Recovery Password Recovery
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-0"></div>
<div> <div>
<p className="text-black dark:text-white"> <p>
Enter your email so we can send you a link to recover your Enter your email so we can send you a link to recover your
account. Make sure to change your password in the profile settings account. Make sure to change your password in the profile settings
afterwards. afterwards.
</p> </p>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-neutral">
You wont get logged in if you haven&apos;t created an account yet. You wont get logged in if you haven&apos;t created an account yet.
</p> </p>
</div> </div>
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">Email</p>
Email
</p>
<TextInput <TextInput
autoFocus autoFocus
type="email" type="email"
placeholder="johnny@example.com" placeholder="johnny@example.com"
value={form.email} value={form.email}
className="bg-white" className="bg-base-100"
onChange={(e) => setForm({ ...form, email: e.target.value })} onChange={(e) => setForm({ ...form, email: e.target.value })}
/> />
</div> </div>
@ -82,10 +80,7 @@ export default function Forgot() {
loading={submitLoader} loading={submitLoader}
/> />
<div className="flex items-baseline gap-1 justify-center"> <div className="flex items-baseline gap-1 justify-center">
<Link <Link href={"/login"} className="block font-bold">
href={"/login"}
className="block text-black dark:text-white font-bold"
>
Go back Go back
</Link> </Link>
</div> </div>

View File

@ -9,14 +9,18 @@ import {
} from "@/types/global"; } from "@/types/global";
import Image from "next/image"; import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief"; import ColorThief, { RGBColor } from "colorthief";
import { useTheme } from "next-themes";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons"; import {
faBoxesStacked,
faFolder,
faLink,
} from "@fortawesome/free-solid-svg-icons";
import useModalStore from "@/store/modals"; import useModalStore from "@/store/modals";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useLocalSettingsStore from "@/store/localSettings";
type LinkContent = { type LinkContent = {
title: string; title: string;
@ -31,10 +35,11 @@ type LinkContent = {
}; };
export default function Index() { export default function Index() {
const { theme } = useTheme();
const { links, getLink } = useLinkStore(); const { links, getLink } = useLinkStore();
const { setModal } = useModalStore(); const { setModal } = useModalStore();
const { settings } = useLocalSettingsStore();
const session = useSession(); const session = useSession();
const userId = session.data?.user.id; const userId = session.data?.user.id;
@ -117,19 +122,19 @@ export default function Index() {
if (colorPalette && banner && bannerInner) { if (colorPalette && banner && bannerInner) {
if (colorPalette[0] && colorPalette[1]) { if (colorPalette[0] && colorPalette[1]) {
banner.style.background = `linear-gradient(to right, ${rgbToHex( banner.style.background = `linear-gradient(to bottom, ${rgbToHex(
colorPalette[0][0], colorPalette[0][0],
colorPalette[0][1], colorPalette[0][1],
colorPalette[0][2] colorPalette[0][2]
)}30, ${rgbToHex( )}20, ${rgbToHex(
colorPalette[1][0], colorPalette[1][0],
colorPalette[1][1], colorPalette[1][1],
colorPalette[1][2] colorPalette[1][2]
)}30)`; )}20)`;
} }
if (colorPalette[2] && colorPalette[3]) { if (colorPalette[2] && colorPalette[3]) {
bannerInner.style.background = `linear-gradient(to left, ${rgbToHex( bannerInner.style.background = `linear-gradient(to bottom, ${rgbToHex(
colorPalette[2][0], colorPalette[2][0],
colorPalette[2][1], colorPalette[2][1],
colorPalette[2][2] colorPalette[2][2]
@ -140,23 +145,19 @@ export default function Index() {
)})30`; )})30`;
} }
} }
}, [colorPalette, theme]); }, [colorPalette]);
return ( return (
<LinkLayout> <LinkLayout>
<div <div className={`flex flex-col max-w-screen-md h-full`}>
className={`flex flex-col max-w-screen-md h-full ${
theme === "dark" ? "banner-dark-mode" : "banner-light-mode"
}`}
>
<div <div
id="link-banner" id="link-banner"
className="link-banner p-5 mb-4 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md" className="link-banner relative bg-opacity-10 border-neutral-content"
> >
<div id="link-banner-inner" className="link-banner-inner"></div> {/* <div id="link-banner-inner" className="link-banner-inner"></div> */}
<div className={`relative flex flex-col gap-3 items-start`}> <div className={`relative flex flex-col gap-3 items-start`}>
<div className="flex gap-3 items-end"> <div className="flex gap-3 items-start">
{!imageError && link?.url && ( {!imageError && link?.url && (
<Image <Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`} src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
@ -164,7 +165,7 @@ export default function Index() {
height={42} height={42}
alt="" alt=""
id={"favicon-" + link.id} id={"favicon-" + link.id}
className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-white dark:border-neutral-900 bg-white dark:bg-neutral-900 aspect-square" className="bg-white shadow rounded-md p-1 select-none mt-1"
draggable="false" draggable="false"
onLoad={(e) => { onLoad={(e) => {
try { try {
@ -183,92 +184,92 @@ export default function Index() {
}} }}
/> />
)} )}
<div className="flex flex-col">
<div className="flex gap-2 text-sm text-gray-500 dark:text-gray-300"> <p className="text-xl">
<p className=" min-w-fit"> {unescapeString(link?.name || link?.description || "")}
{link?.createdAt
? new Date(link?.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: undefined}
</p> </p>
{link?.url ? ( {link?.url ? (
<> <Link
<p></p> href={link?.url || ""}
<Link title={link?.url}
href={link?.url || ""} target="_blank"
title={link?.url} className="hover:opacity-60 duration-100 break-all text-sm flex items-center gap-1 text-neutral w-fit"
target="_blank" >
className="hover:opacity-60 duration-100 break-all" <FontAwesomeIcon icon={faLink} className="w-4 h-4" />
>
{isValidUrl(link?.url || "") {isValidUrl(link?.url || "")
? new URL(link?.url as string).host ? new URL(link?.url as string).host
: undefined} : undefined}
</Link> </Link>
</>
) : undefined} ) : undefined}
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex gap-1 items-center flex-wrap">
<p className="capitalize text-2xl sm:text-3xl font-thin"> <Link
{unescapeString(link?.name || link?.description || "")} href={`/collections/${link?.collection.id}`}
</p> className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
<div className="flex gap-1 items-center flex-wrap"> <FontAwesomeIcon
<Link icon={faFolder}
href={`/collections/${link?.collection.id}`} className="w-5 h-5 drop-shadow"
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10" style={{ color: link?.collection.color }}
/>
<p
title={link?.collection.name}
className="text-lg truncate max-w-[12rem]"
> >
<FontAwesomeIcon {link?.collection.name}
icon={faFolder} </p>
className="w-5 h-5 drop-shadow" </Link>
style={{ color: link?.collection.color }} {link?.tags.map((e, i) => (
/> <Link key={i} href={`/tags/${e.id}`} className="z-10">
<p <p
title={link?.collection.name} title={e.name}
className="text-black dark:text-white text-lg truncate max-w-[12rem]" className="btn btn-xs btn-ghost truncate max-w-[19rem]"
> >
{link?.collection.name} #{e.name}
</p> </p>
</Link> </Link>
{link?.tags.map((e, i) => ( ))}
<Link key={i} href={`/tags/${e.id}`} className="z-10">
<p
title={e.name}
className="px-2 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]"
>
{e.name}
</p>
</Link>
))}
</div>
</div> </div>
<p className="min-w-fit text-sm text-neutral">
{link?.createdAt
? new Date(link?.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: undefined}
</p>
{link?.name ? <p>{link?.description}</p> : undefined}
</div> </div>
</div> </div>
<div className="divider"></div>
<div className="flex flex-col gap-5 h-full"> <div className="flex flex-col gap-5 h-full">
{link?.readabilityPath?.startsWith("archives") ? ( {link?.readabilityPath?.startsWith("archives") ? (
<div <div
className="line-break px-3 reader-view" className="line-break px-1 reader-view"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(linkContent?.content || "") || "", __html: DOMPurify.sanitize(linkContent?.content || "") || "",
}} }}
></div> ></div>
) : ( ) : (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800"> <div className="border border-solid border-neutral-content w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-base-200">
{link?.readabilityPath === "pending" ? ( {link?.readabilityPath === "pending" ? (
<p className="text-center"> <p className="text-center">
Generating readable format, please wait... Generating readable format, please wait...
</p> </p>
) : ( ) : (
<> <>
<p className="text-center text-2xl text-black dark:text-white"> <p className="text-center text-2xl">
There is no reader view for this webpage There is no reader view for this webpage
</p> </p>
<p className="text-center text-sm text-black dark:text-white"> <p className="text-center text-sm">
{link?.collection.ownerId === userId {link?.collection.ownerId === userId
? "You can update (refetch) the preserved formats by managing them below" ? "You can update (refetch) the preserved formats by managing them below"
: "The collections owners can refetch the preserved formats"} : "The collections owners can refetch the preserved formats"}

View File

@ -5,14 +5,13 @@ import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import { faLink, faSort } from "@fortawesome/free-solid-svg-icons"; import { faLink } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react"; import { useState } from "react";
export default function Links() { export default function Links() {
const { links } = useLinkStore(); const { links } = useLinkStore();
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({ sort: sortBy }); useLinks({ sort: sortBy });
@ -24,39 +23,17 @@ export default function Links() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FontAwesomeIcon <FontAwesomeIcon
icon={faLink} icon={faLink}
className="sm:w-10 sm:h-10 w-6 h-6 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-10 sm:h-10 w-6 h-6 text-primary drop-shadow"
/> />
<div> <div>
<p className="text-3xl capitalize text-black dark:text-white font-thin"> <p className="text-3xl capitalize font-thin">All Links</p>
All Links
</p>
<p className="text-black dark:text-white"> <p>Links from every Collections</p>
Links from every Collections
</p>
</div> </div>
</div> </div>
<div className="relative mt-2"> <div className="relative mt-2">
<div <SortDropdown sortBy={sortBy} setSort={setSortBy} />
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div> </div>
</div> </div>
{links[0] ? ( {links[0] ? (

View File

@ -1,18 +1,16 @@
import LinkCard from "@/components/LinkCard"; import LinkCard from "@/components/LinkCard";
import NoLinksFound from "@/components/NoLinksFound";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { Sort } from "@/types/global"; import { Sort } from "@/types/global";
import { faSort, faThumbTack } from "@fortawesome/free-solid-svg-icons"; import { faThumbTack } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react"; import { useState } from "react";
export default function PinnedLinks() { export default function PinnedLinks() {
const { links } = useLinkStore(); const { links } = useLinkStore();
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({ sort: sortBy, pinnedOnly: true }); useLinks({ sort: sortBy, pinnedOnly: true });
@ -24,39 +22,17 @@ export default function PinnedLinks() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<FontAwesomeIcon <FontAwesomeIcon
icon={faThumbTack} icon={faThumbTack}
className="sm:w-10 sm:h-10 w-6 h-6 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-10 sm:h-10 w-6 h-6 text-primary drop-shadow"
/> />
<div> <div>
<p className="text-3xl capitalize text-black dark:text-white font-thin"> <p className="text-3xl capitalize font-thin">Pinned Links</p>
Pinned Links
</p>
<p className="text-black dark:text-white"> <p>Pinned Links from your Collections</p>
Pinned Links from your Collections
</p>
</div> </div>
</div> </div>
<div className="relative mt-2"> <div className="relative mt-2">
<div <SortDropdown sortBy={sortBy} setSort={setSortBy} />
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div> </div>
</div> </div>
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
@ -68,12 +44,12 @@ export default function PinnedLinks() {
) : ( ) : (
<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-sky-100 dark:border-neutral-700 w-full mx-auto p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800" 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 text-black dark:text-white"> <p className="text-center text-2xl">
Pin Your Favorite Links Here! Pin Your Favorite Links Here!
</p> </p>
<p className="text-center mx-auto max-w-96 w-fit text-gray-500 dark:text-gray-300 text-sm mt-2"> <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 You can Pin your favorite Links by clicking on the three dots on
each Link and clicking{" "} each Link and clicking{" "}
<span className="font-semibold">Pin to Dashboard</span>. <span className="font-semibold">Pin to Dashboard</span>.

View File

@ -76,18 +76,17 @@ export default function Login() {
return ( return (
<CenteredForm text="Sign in to your account"> <CenteredForm text="Sign in to your account">
<form onSubmit={loginUser}> <form onSubmit={loginUser}>
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
{process.env.NEXT_PUBLIC_DISABLE_LOGIN !== "true" ? ( {process.env.NEXT_PUBLIC_DISABLE_LOGIN !== "true" ? (
<div> <div>
<p className="text-3xl text-black dark:text-white text-center font-extralight"> <p className="text-3xl text-center font-extralight">
Enter your credentials Enter your credentials
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-0"></div>
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">
Username Username
{emailEnabled ? " or Email" : undefined} {emailEnabled ? " or Email" : undefined}
</p> </p>
@ -96,29 +95,24 @@ export default function Login() {
autoFocus={true} autoFocus={true}
placeholder="johnny" placeholder="johnny"
value={form.username} value={form.username}
className="bg-white" className="bg-base-100"
onChange={(e) => setForm({ ...form, username: e.target.value })} onChange={(e) => setForm({ ...form, username: e.target.value })}
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">Password</p>
Password
</p>
<TextInput <TextInput
type="password" type="password"
placeholder="••••••••••••••" placeholder="••••••••••••••"
value={form.password} value={form.password}
className="bg-white" className="bg-base-100"
onChange={(e) => setForm({ ...form, password: e.target.value })} onChange={(e) => setForm({ ...form, password: e.target.value })}
/> />
{emailEnabled && ( {emailEnabled && (
<div className="w-fit ml-auto mt-1"> <div className="w-fit ml-auto mt-1">
<Link <Link href={"/forgot"} className="text-neutral font-semibold">
href={"/forgot"}
className="text-gray-500 dark:text-gray-400 font-semibold"
>
Forgot Password? Forgot Password?
</Link> </Link>
</div> </div>
@ -154,13 +148,8 @@ export default function Login() {
{process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === {process.env.NEXT_PUBLIC_DISABLE_REGISTRATION ===
"true" ? undefined : ( "true" ? undefined : (
<div className="flex items-baseline gap-1 justify-center"> <div className="flex items-baseline gap-1 justify-center">
<p className="w-fit text-gray-500 dark:text-gray-400"> <p className="w-fit text-neutral">New here?</p>
New here? <Link href={"/register"} className="block font-semibold">
</p>
<Link
href={"/register"}
className="block text-black dark:text-white font-semibold"
>
Sign Up Sign Up
</Link> </Link>
</div> </div>

View File

@ -9,18 +9,16 @@ import Head from "next/head";
import useLinks from "@/hooks/useLinks"; import useLinks from "@/hooks/useLinks";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import ProfilePhoto from "@/components/ProfilePhoto"; import ProfilePhoto from "@/components/ProfilePhoto";
import useModalStore from "@/store/modals";
import ModalManagement from "@/components/ModalManagement"; import ModalManagement from "@/components/ModalManagement";
import ToggleDarkMode from "@/components/ToggleDarkMode"; import ToggleDarkMode from "@/components/ToggleDarkMode";
import { useTheme } from "next-themes";
import getPublicUserData from "@/lib/client/getPublicUserData"; import getPublicUserData from "@/lib/client/getPublicUserData";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import PublicSearchBar from "@/components/PublicPage/PublicSearchBar";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFilter, faSort } from "@fortawesome/free-solid-svg-icons";
import FilterSearchDropdown from "@/components/FilterSearchDropdown"; import FilterSearchDropdown from "@/components/FilterSearchDropdown";
import SortDropdown from "@/components/SortDropdown"; import SortDropdown from "@/components/SortDropdown";
import useLocalSettingsStore from "@/store/localSettings";
import SearchBar from "@/components/SearchBar";
import EditCollectionSharingModal from "@/components/ModalContent/EditCollectionSharingModal";
const cardVariants: Variants = { const cardVariants: Variants = {
offscreen: { offscreen: {
@ -38,15 +36,8 @@ const cardVariants: Variants = {
export default function PublicCollections() { export default function PublicCollections() {
const { links } = useLinkStore(); const { links } = useLinkStore();
const { modal, setModal } = useModalStore();
useEffect(() => { const { settings } = useLocalSettingsStore();
modal
? (document.body.style.overflow = "hidden")
: (document.body.style.overflow = "auto");
}, [modal]);
const { theme } = useTheme();
const router = useRouter(); const router = useRouter();
@ -65,8 +56,6 @@ export default function PublicCollections() {
tags: true, tags: true,
}); });
const [filterDropdown, setFilterDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({ useLinks({
@ -101,13 +90,16 @@ export default function PublicCollections() {
fetchOwner(); fetchOwner();
}, [collection]); }, [collection]);
const [editCollectionSharingModal, setEditCollectionSharingModal] =
useState(false);
return collection ? ( return collection ? (
<div <div
className="h-screen" className="h-screen"
style={{ style={{
backgroundImage: `linear-gradient(${collection?.color}30 10%, ${ backgroundImage: `linear-gradient(${collection?.color}30 10%, ${
theme === "dark" ? "#262626" : "#f3f4f6" settings.theme === "dark" ? "#262626" : "#f3f4f6"
} 50%, ${theme === "dark" ? "#171717" : "#ffffff"} 100%)`, } 18rem, ${settings.theme === "dark" ? "#171717" : "#ffffff"} 100%)`,
}} }}
> >
<ModalManagement /> <ModalManagement />
@ -128,63 +120,57 @@ export default function PublicCollections() {
{collection.name} {collection.name}
</p> </p>
<div className="flex gap-2 items-center mt-8 min-w-fit"> <div className="flex gap-2 items-center mt-8 min-w-fit">
<ToggleDarkMode className="w-8 h-8 flex" /> <ToggleDarkMode />
<Link href="https://linkwarden.app/" target="_blank"> <Link href="https://linkwarden.app/" target="_blank">
<Image <Image
src={`/icon.png`} src={`/icon.png`}
width={551} width={551}
height={551} height={551}
alt="Linkwarden" alt="Linkwarden"
title="Linkwarden" title="Created with Linkwarden"
className="h-8 w-fit mx-auto" className="h-8 w-fit mx-auto rounded"
/> />
</Link> </Link>
</div> </div>
</div> </div>
<div> <div className="mt-3">
<div className={`min-w-[15rem]`}> <div className={`min-w-[15rem]`}>
<div <div className="flex gap-1 justify-center sm:justify-end items-center w-fit">
onClick={() => <div
setModal({ className="flex items-center btn px-2 btn-ghost rounded-full"
modal: "COLLECTION", onClick={() => setEditCollectionSharingModal(true)}
state: true, >
method: "VIEW_TEAM", {collectionOwner.id ? (
isOwner: false, <ProfilePhoto
active: collection, src={collectionOwner.image || undefined}
defaultIndex: 0, name={collectionOwner.name}
}) />
} ) : undefined}
className="hover:opacity-80 duration-100 flex justify-center sm:justify-end items-start w-fit cursor-pointer" {collection.members
> .sort((a, b) => (a.userId as number) - (b.userId as number))
{collectionOwner.id ? ( .map((e, i) => {
<ProfilePhoto return (
src={ <ProfilePhoto
collectionOwner.image ? collectionOwner.image : undefined key={i}
} src={e.user.image ? e.user.image : undefined}
className={`w-8 h-8 border-2`} className="-ml-3"
/> name={e.user.name}
) : undefined} />
{collection.members );
.sort((a, b) => (a.userId as number) - (b.userId as number)) })
.map((e, i) => { .slice(0, 3)}
return ( {collection.members.length - 3 > 0 ? (
<ProfilePhoto <div className={`avatar drop-shadow-md placeholder -ml-3`}>
key={i} <div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
src={e.user.image ? e.user.image : undefined} <span>+{collection.members.length - 3}</span>
className={`w-8 h-8 border-2`} </div>
/> </div>
); ) : null}
}) </div>
.slice(0, 3)}
{collection?.members.length &&
collection.members.length - 3 > 0 ? (
<div className="w-8 h-8 min-w-[2rem] text-white text-sm flex items-center justify-center rounded-full border-2 bg-sky-600 dark:bg-sky-600 border-slate-200 dark:border-neutral-700">
+{collection?.members?.length - 3}
</div>
) : null}
<p className="ml-2 mt-1 text-gray-500 dark:text-gray-300"> <p className="text-neutral text-sm font-semibold">
By {collectionOwner.name} By {collectionOwner.name}
{collection.members.length > 0 {collection.members.length > 0
? ` and ${collection.members.length} others` ? ` and ${collection.members.length} others`
@ -197,57 +183,24 @@ export default function PublicCollections() {
<p className="mt-5">{collection.description}</p> <p className="mt-5">{collection.description}</p>
<hr className="mt-5 border-1 border-neutral-500" /> <div className="divider mt-5 mb-0"></div>
<div className="flex mb-5 mt-10 flex-col gap-5"> <div className="flex mb-5 mt-10 flex-col gap-5">
<div className="flex justify-between"> <div className="flex justify-between">
<PublicSearchBar <SearchBar
placeHolder={`Search ${collection._count?.links} Links`} placeholder={`Search ${collection._count?.links} Links`}
/> />
<div className="flex gap-3 items-center"> <div className="flex gap-2 items-center">
<div className="relative"> <div className="relative">
<div <FilterSearchDropdown
onClick={() => setFilterDropdown(!filterDropdown)} searchFilter={searchFilter}
id="filter-dropdown" setSearchFilter={setSearchFilter}
className="inline-flex rounded-md cursor-pointer hover:bg-neutral-500 hover:bg-opacity-40 duration-100 p-1" />
>
<FontAwesomeIcon
icon={faFilter}
id="filter-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{filterDropdown ? (
<FilterSearchDropdown
setFilterDropdown={setFilterDropdown}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
) : null}
</div> </div>
<div className="relative"> <div className="relative">
<div <SortDropdown sortBy={sortBy} setSort={setSortBy} />
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-neutral-500 hover:bg-opacity-40 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -271,11 +224,17 @@ export default function PublicCollections() {
})} })}
</div> </div>
{/* <p className="text-center text-gray-500"> {/* <p className="text-center text-neutral">
List created with <span className="text-black">Linkwarden.</span> List created with <span className="text-black">Linkwarden.</span>
</p> */} </p> */}
</div> </div>
</div> </div>
{editCollectionSharingModal ? (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
) : undefined}
</div> </div>
) : ( ) : (
<></> <></>

View File

@ -9,14 +9,14 @@ import {
} from "@/types/global"; } from "@/types/global";
import Image from "next/image"; import Image from "next/image";
import ColorThief, { RGBColor } from "colorthief"; import ColorThief, { RGBColor } from "colorthief";
import { useTheme } from "next-themes";
import unescapeString from "@/lib/client/unescapeString"; import unescapeString from "@/lib/client/unescapeString";
import isValidUrl from "@/lib/client/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons"; import { faBoxesStacked, faFolder } from "@fortawesome/free-solid-svg-icons";
import useModalStore from "@/store/modals"; import useModalStore from "@/store/modals";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import useLocalSettingsStore from "@/store/localSettings";
type LinkContent = { type LinkContent = {
title: string; title: string;
@ -31,10 +31,11 @@ type LinkContent = {
}; };
export default function Index() { export default function Index() {
const { theme } = useTheme();
const { links, getLink } = useLinkStore(); const { links, getLink } = useLinkStore();
const { setModal } = useModalStore(); const { setModal } = useModalStore();
const { settings } = useLocalSettingsStore();
const session = useSession(); const session = useSession();
const userId = session.data?.user.id; const userId = session.data?.user.id;
@ -140,18 +141,18 @@ export default function Index() {
)})30`; )})30`;
} }
} }
}, [colorPalette, theme]); }, [colorPalette]);
return ( return (
<LinkLayout> <LinkLayout>
<div <div
className={`flex flex-col max-w-screen-md h-full ${ className={`flex flex-col max-w-screen-md h-full ${
theme === "dark" ? "banner-dark-mode" : "banner-light-mode" settings.theme === "dark" ? "banner-dark-mode" : "banner-light-mode"
}`} }`}
> >
<div <div
id="link-banner" id="link-banner"
className="link-banner p-5 mb-4 relative bg-opacity-10 border border-solid border-sky-100 dark:border-neutral-700 shadow-md" className="link-banner p-5 mb-4 relative bg-opacity-10 border border-solid border-neutral-content shadow-md"
> >
<div id="link-banner-inner" className="link-banner-inner"></div> <div id="link-banner-inner" className="link-banner-inner"></div>
@ -164,7 +165,7 @@ export default function Index() {
height={42} height={42}
alt="" alt=""
id={"favicon-" + link.id} id={"favicon-" + link.id}
className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-white dark:border-neutral-900 bg-white dark:bg-neutral-900 aspect-square" className="select-none mt-2 w-10 rounded-md shadow border-[3px] border-base-100 bg-base-100 aspect-square"
draggable="false" draggable="false"
onLoad={(e) => { onLoad={(e) => {
try { try {
@ -184,7 +185,7 @@ export default function Index() {
/> />
)} )}
<div className="flex gap-2 text-sm text-gray-500 dark:text-gray-300"> <div className="flex gap-2 text-sm text-neutral">
<p className=" min-w-fit"> <p className=" min-w-fit">
{link?.createdAt {link?.createdAt
? new Date(link?.createdAt).toLocaleString("en-US", { ? new Date(link?.createdAt).toLocaleString("en-US", {
@ -229,19 +230,19 @@ export default function Index() {
/> />
<p <p
title={link?.collection?.name} title={link?.collection?.name}
className="text-black dark:text-white text-lg truncate max-w-[12rem]" className="text-lg truncate max-w-[12rem]"
> >
{link?.collection?.name} {link?.collection?.name}
</p> </p>
</Link> </Link>
{link?.tags.map((e, i) => ( {link?.tags.map((e, i) => (
<Link key={i} href={`/tags/${e.id}`} className="z-10"> <Link
<p key={i}
title={e.name} href={"/public/collections/20?q=" + e.name}
className="px-2 py-1 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]" title={e.name}
> className="z-10 btn btn-xs btn-ghost truncate max-w-[19rem]"
{e.name} >
</p> #{e.name}
</Link> </Link>
))} ))}
</div> </div>
@ -258,17 +259,17 @@ export default function Index() {
}} }}
></div> ></div>
) : ( ) : (
<div className="border border-solid border-sky-100 dark:border-neutral-700 w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-gray-50 dark:bg-neutral-800"> <div className="border border-solid border-neutral-content w-full h-full flex flex-col justify-center p-10 rounded-2xl bg-base-200">
{link?.readabilityPath === "pending" ? ( {link?.readabilityPath === "pending" ? (
<p className="text-center"> <p className="text-center">
Generating readable format, please wait... Generating readable format, please wait...
</p> </p>
) : ( ) : (
<> <>
<p className="text-center text-2xl text-black dark:text-white"> <p className="text-center text-2xl">
There is no reader view for this webpage There is no reader view for this webpage
</p> </p>
<p className="text-center text-sm text-black dark:text-white"> <p className="text-center text-sm">
{link?.collection?.ownerId === userId {link?.collection?.ownerId === userId
? "You can update (refetch) the preserved formats by managing them below" ? "You can update (refetch) the preserved formats by managing them below"
: "The collections owners can refetch the preserved formats"} : "The collections owners can refetch the preserved formats"}

View File

@ -104,7 +104,7 @@ export default function Register() {
} }
> >
{process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? ( {process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" ? (
<div className="p-4 flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700"> <div className="p-4 flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
<p> <p>
Registration is disabled for this instance, please contact the admin Registration is disabled for this instance, please contact the admin
in case of any issues. in case of any issues.
@ -112,37 +112,33 @@ export default function Register() {
</div> </div>
) : ( ) : (
<form onSubmit={registerUser}> <form onSubmit={registerUser}>
<div className="p-4 flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full mx-auto bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100 dark:border-neutral-700"> <div className="p-4 flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full mx-auto bg-base-200 rounded-2xl shadow-md border border-neutral-content">
<p className="text-3xl text-black dark:text-white text-center font-extralight"> <p className="text-3xl text-center font-extralight">
Enter your details Enter your details
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-0"></div>
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">Display Name</p>
Display Name
</p>
<TextInput <TextInput
autoFocus={true} autoFocus={true}
placeholder="Johnny" placeholder="Johnny"
value={form.name} value={form.name}
className="bg-white" className="bg-base-100"
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
/> />
</div> </div>
{emailEnabled ? undefined : ( {emailEnabled ? undefined : (
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">Username</p>
Username
</p>
<TextInput <TextInput
placeholder="john" placeholder="john"
value={form.username} value={form.username}
className="bg-white" className="bg-base-100"
onChange={(e) => onChange={(e) =>
setForm({ ...form, username: e.target.value }) setForm({ ...form, username: e.target.value })
} }
@ -152,36 +148,32 @@ export default function Register() {
{emailEnabled ? ( {emailEnabled ? (
<div> <div>
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">Email</p>
Email
</p>
<TextInput <TextInput
type="email" type="email"
placeholder="johnny@example.com" placeholder="johnny@example.com"
value={form.email} value={form.email}
className="bg-white" className="bg-base-100"
onChange={(e) => setForm({ ...form, email: e.target.value })} onChange={(e) => setForm({ ...form, email: e.target.value })}
/> />
</div> </div>
) : undefined} ) : undefined}
<div className="w-full"> <div className="w-full">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">Password</p>
Password
</p>
<TextInput <TextInput
type="password" type="password"
placeholder="••••••••••••••" placeholder="••••••••••••••"
value={form.password} value={form.password}
className="bg-white" className="bg-base-100"
onChange={(e) => setForm({ ...form, password: e.target.value })} onChange={(e) => setForm({ ...form, password: e.target.value })}
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
<p className="text-sm text-black dark:text-white w-fit font-semibold mb-1"> <p className="text-sm w-fit font-semibold mb-1">
Confirm Password Confirm Password
</p> </p>
@ -189,7 +181,7 @@ export default function Register() {
type="password" type="password"
placeholder="••••••••••••••" placeholder="••••••••••••••"
value={form.passwordConfirmation} value={form.passwordConfirmation}
className="bg-white" className="bg-base-100"
onChange={(e) => onChange={(e) =>
setForm({ ...form, passwordConfirmation: e.target.value }) setForm({ ...form, passwordConfirmation: e.target.value })
} }
@ -198,7 +190,7 @@ export default function Register() {
{process.env.NEXT_PUBLIC_STRIPE ? ( {process.env.NEXT_PUBLIC_STRIPE ? (
<div> <div>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-neutral">
By signing up, you agree to our{" "} By signing up, you agree to our{" "}
<Link <Link
href="https://linkwarden.app/tos" href="https://linkwarden.app/tos"
@ -215,7 +207,7 @@ export default function Register() {
</Link> </Link>
. .
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-neutral">
Need help?{" "} Need help?{" "}
<Link <Link
href="mailto:support@linkwarden.app" href="mailto:support@linkwarden.app"
@ -235,13 +227,8 @@ export default function Register() {
<p className="text-center w-full font-bold">Sign Up</p> <p className="text-center w-full font-bold">Sign Up</p>
</button> </button>
<div className="flex items-baseline gap-1 justify-center"> <div className="flex items-baseline gap-1 justify-center">
<p className="w-fit text-gray-500 dark:text-gray-400"> <p className="w-fit text-neutral">Already have an account?</p>
Already have an account? <Link href={"/login"} className="block font-bold">
</p>
<Link
href={"/login"}
className="block text-black dark:text-white font-bold"
>
Login Login
</Link> </Link>
</div> </div>

View File

@ -24,7 +24,6 @@ export default function Search() {
}); });
const [filterDropdown, setFilterDropdown] = useState(false); const [filterDropdown, setFilterDropdown] = useState(false);
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({ useLinks({
@ -45,57 +44,24 @@ export default function Search() {
<div className="flex gap-2"> <div className="flex gap-2">
<FontAwesomeIcon <FontAwesomeIcon
icon={faSearch} icon={faSearch}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500 drop-shadow" className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-primary drop-shadow"
/> />
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white font-thin"> <p className="sm:text-4xl text-3xl capitalize font-thin">
Search Results Search Results
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-3 items-center"> <div className="flex gap-2 items-center">
<div className="relative"> <div className="relative">
<div <FilterSearchDropdown
onClick={() => setFilterDropdown(!filterDropdown)} searchFilter={searchFilter}
id="filter-dropdown" setSearchFilter={setSearchFilter}
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" />
>
<FontAwesomeIcon
icon={faFilter}
id="filter-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{filterDropdown ? (
<FilterSearchDropdown
setFilterDropdown={setFilterDropdown}
searchFilter={searchFilter}
setSearchFilter={setSearchFilter}
/>
) : null}
</div> </div>
<div className="relative"> <div className="relative">
<div <SortDropdown sortBy={sortBy} setSort={setSortBy} />
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -106,7 +72,7 @@ export default function Search() {
})} })}
</div> </div>
) : ( ) : (
<p className="text-black dark:text-white"> <p>
Nothing found.{" "} Nothing found.{" "}
<span className="font-bold text-xl" title="Shruggie"> <span className="font-bold text-xl" title="Shruggie">
¯\_()_/¯ ¯\_()_/¯

View File

@ -153,37 +153,40 @@ export default function Account() {
<SettingsLayout> <SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Account Settings</p> <p className="capitalize text-3xl font-thin inline">Account Settings</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-5">
<div className="grid sm:grid-cols-2 gap-3 auto-rows-auto"> <div className="grid sm:grid-cols-2 gap-3 auto-rows-auto">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div> <div>
<p className="text-black dark:text-white mb-2">Display Name</p> <p className="mb-2">Display Name</p>
<TextInput <TextInput
value={user.name || ""} value={user.name || ""}
className="bg-base-200"
onChange={(e) => setUser({ ...user, name: e.target.value })} onChange={(e) => setUser({ ...user, name: e.target.value })}
/> />
</div> </div>
<div> <div>
<p className="text-black dark:text-white mb-2">Username</p> <p className="mb-2">Username</p>
<TextInput <TextInput
value={user.username || ""} value={user.username || ""}
className="bg-base-200"
onChange={(e) => setUser({ ...user, username: e.target.value })} onChange={(e) => setUser({ ...user, username: e.target.value })}
/> />
</div> </div>
{emailEnabled ? ( {emailEnabled ? (
<div> <div>
<p className="text-black dark:text-white mb-2">Email</p> <p className="mb-2">Email</p>
{user.email !== account.email && {user.email !== account.email &&
process.env.NEXT_PUBLIC_STRIPE === "true" ? ( process.env.NEXT_PUBLIC_STRIPE === "true" ? (
<p className="text-gray-500 dark:text-gray-400 mb-2 text-sm"> <p className="text-neutral mb-2 text-sm">
Updating this field will change your billing email as well Updating this field will change your billing email as well
</p> </p>
) : undefined} ) : undefined}
<TextInput <TextInput
value={user.email || ""} value={user.email || ""}
className="bg-base-200"
onChange={(e) => setUser({ ...user, email: e.target.value })} onChange={(e) => setUser({ ...user, email: e.target.value })}
/> />
</div> </div>
@ -191,14 +194,12 @@ export default function Account() {
</div> </div>
<div className="sm:row-span-2 sm:justify-self-center mx-auto my-3"> <div className="sm:row-span-2 sm:justify-self-center mx-auto my-3">
<p className="text-black dark:text-white mb-2 text-center"> <p className="mb-2 text-center">Profile Photo</p>
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}
src={user.image ? user.image : undefined} src={user.image ? user.image : undefined}
className="h-auto border-none w-28" dimensionClass="w-28 h-28"
/> />
{user.image && ( {user.image && (
<div <div
@ -208,13 +209,13 @@ export default function Account() {
image: "", image: "",
}) })
} }
className="absolute top-1 left-1 w-5 h-5 flex items-center justify-center border p-1 border-slate-200 dark:border-neutral-700 rounded-full bg-white dark:bg-neutral-800 text-center select-none cursor-pointer duration-100 hover:text-red-500" className="absolute top-1 left-1 btn btn-xs btn-circle btn-neutral btn-outline bg-base-100"
> >
<FontAwesomeIcon icon={faClose} className="w-3 h-3" /> <FontAwesomeIcon icon={faClose} className="w-3 h-3" />
</div> </div>
)} )}
<div className="absolute -bottom-3 left-0 right-0 mx-auto w-fit text-center"> <div className="absolute -bottom-3 left-0 right-0 mx-auto w-fit text-center">
<label className="border border-slate-200 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 px-2 text-center select-none cursor-pointer duration-100 hover:border-sky-300 hover:dark:border-sky-600"> <label className="btn btn-xs btn-neutral btn-outline bg-base-100">
Browse... Browse...
<input <input
type="file" type="file"
@ -232,85 +233,74 @@ export default function Account() {
<div> <div>
<div className="flex items-center gap-2 w-full rounded-md h-8"> <div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="text-black dark:text-white truncate w-full pr-7 text-3xl font-thin"> <p className="truncate w-full pr-7 text-3xl font-thin">
Import & Export Import & Export
</p> </p>
</div> </div>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<div className="flex gap-3 flex-col"> <div className="flex gap-3 flex-col">
<div> <div>
<p className="text-black dark:text-white mb-2"> <p className="mb-2">Import your data from other platforms.</p>
Import your data from other platforms. <div className="dropdown dropdown-bottom">
</p>
<div
onClick={() => setImportDropdown(true)}
className="w-fit relative"
id="import-dropdown"
>
<div <div
tabIndex={0}
role="button"
className="flex gap-2 text-sm btn btn-outline btn-neutral btn-xs"
id="import-dropdown" id="import-dropdown"
className="border border-slate-200 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 px-2 text-center select-none cursor-pointer duration-100 hover:border-sky-300 hover:dark:border-sky-600"
> >
Import From Import From
</div> </div>
{importDropdown ? ( <ul className="shadow menu dropdown-content z-[1] bg-base-200 border border-neutral-content rounded-box mt-1 w-60">
<ClickAwayHandler <li>
onClickOutside={(e: Event) => { <label
const target = e.target as HTMLInputElement; tabIndex={0}
if (target.id !== "import-dropdown") role="button"
setImportDropdown(false); htmlFor="import-linkwarden-file"
}} title="JSON File"
className={`absolute top-7 left-0 w-52 py-1 shadow-md border border-sky-100 dark:border-neutral-700 bg-gray-50 dark:bg-neutral-800 rounded-md flex flex-col z-20`} >
> From Linkwarden
<div className="cursor-pointer rounded-md"> <input
<label type="file"
htmlFor="import-linkwarden-file" name="photo"
title="JSON File" id="import-linkwarden-file"
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer" accept=".json"
> className="hidden"
Linkwarden File... onChange={(e) =>
<input importBookmarks(e, MigrationFormat.linkwarden)
type="file" }
name="photo" />
id="import-linkwarden-file" </label>
accept=".json" </li>
className="hidden" <li>
onChange={(e) => <label
importBookmarks(e, MigrationFormat.linkwarden) tabIndex={0}
} role="button"
/> htmlFor="import-html-file"
</label> title="HTML File"
<label >
htmlFor="import-html-file" From Bookmarks HTML file
title="HTML File" <input
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 cursor-pointer" type="file"
> name="photo"
Bookmarks HTML file... id="import-html-file"
<input accept=".html"
type="file" className="hidden"
name="photo" onChange={(e) =>
id="import-html-file" importBookmarks(e, MigrationFormat.htmlFile)
accept=".html" }
className="hidden" />
onChange={(e) => </label>
importBookmarks(e, MigrationFormat.htmlFile) </li>
} </ul>
/>
</label>
</div>
</ClickAwayHandler>
) : null}
</div> </div>
</div> </div>
<div> <div>
<p className="text-black dark:text-white mb-2"> <p className="mb-2">Download your data instantly.</p>
Download your data instantly.
</p>
<Link className="w-fit" href="/api/v1/migration"> <Link className="w-fit" href="/api/v1/migration">
<div className="border w-fit border-slate-200 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 px-2 text-center select-none cursor-pointer duration-100 hover:border-sky-300 hover:dark:border-sky-600"> <div className="btn btn-outline btn-neutral btn-xs">
Export Data Export Data
</div> </div>
</Link> </Link>
@ -320,12 +310,12 @@ export default function Account() {
<div> <div>
<div className="flex items-center gap-2 w-full rounded-md h-8"> <div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="text-black dark:text-white truncate w-full pr-7 text-3xl font-thin"> <p className="truncate w-full pr-7 text-3xl font-thin">
Profile Visibility Profile Visibility
</p> </p>
</div> </div>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<Checkbox <Checkbox
label="Make profile private" label="Make profile private"
@ -333,21 +323,19 @@ export default function Account() {
onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })} onClick={() => setUser({ ...user, isPrivate: !user.isPrivate })}
/> />
<p className="text-gray-500 dark:text-gray-300 text-sm"> <p className="text-neutral text-sm">
This will limit who can find and add you to new Collections. This will limit who can find and add you to new Collections.
</p> </p>
{user.isPrivate && ( {user.isPrivate && (
<div className="pl-5"> <div className="pl-5">
<p className="text-black dark:text-white mt-2"> <p className="mt-2">Whitelisted Users</p>
Whitelisted Users <p className="text-neutral text-sm mb-3">
</p>
<p className="text-gray-500 dark:text-gray-300 text-sm mb-3">
Please provide the Username of the users you wish to grant Please provide the Username of the users you wish to grant
visibility to your profile. Separated by comma. visibility to your profile. Separated by comma.
</p> </p>
<textarea <textarea
className="w-full resize-none border rounded-md duration-100 bg-gray-50 dark:bg-neutral-950 p-2 outline-none border-sky-100 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600" className="w-full resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder="Your profile is hidden from everyone right now..." placeholder="Your profile is hidden from everyone right now..."
value={whitelistedUsersTextbox} value={whitelistedUsersTextbox}
onChange={(e) => setWhiteListedUsersTextbox(e.target.value)} onChange={(e) => setWhiteListedUsersTextbox(e.target.value)}
@ -370,7 +358,7 @@ export default function Account() {
</p> </p>
</div> </div>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<p> <p>
This will permanently delete ALL the Links, Collections, Tags, and This will permanently delete ALL the Links, Collections, Tags, and
@ -381,14 +369,14 @@ export default function Account() {
You will be prompted to enter your password before the deletion You will be prompted to enter your password before the deletion
process. process.
</p> </p>
<Link
href="/settings/delete"
className="mx-auto lg:mx-0 text-white mt-3 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>
</Link>
</div> </div>
<Link
href="/settings/delete"
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>
</Link>
</div> </div>
</SettingsLayout> </SettingsLayout>
); );

View File

@ -56,10 +56,10 @@ export default function Api() {
<SettingsLayout> <SettingsLayout>
<p className="capitalize text-3xl font-thin inline">API Keys (Soon)</p> <p className="capitalize text-3xl font-thin inline">API Keys (Soon)</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="badge bg-orange-500 rounded-md border border-black w-fit px-2 text-black"> <div className="badge badge-warning rounded-md w-fit p-4">
Status: Under Development Status: Under Development
</div> </div>
@ -67,7 +67,7 @@ export default function Api() {
<p> <p>
For now, you can <i>temporarily</i> use your{" "} For now, you can <i>temporarily</i> use your{" "}
<code className="text-xs whitespace-nowrap bg-gray-500/40 rounded-md px-2 py-1"> <code className="text-xs whitespace-nowrap bg-black/40 rounded-md px-2 py-1">
next-auth.session-token next-auth.session-token
</code>{" "} </code>{" "}
in your browser cookies as the API key for your integrations. in your browser cookies as the API key for your integrations.

View File

@ -1,23 +1,18 @@
import SettingsLayout from "@/layouts/SettingsLayout"; import SettingsLayout from "@/layouts/SettingsLayout";
import { useTheme } from "next-themes";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { faClose } from "@fortawesome/free-solid-svg-icons";
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 TextInput from "@/components/TextInput";
import { resizeImage } from "@/lib/client/resizeImage";
import ProfilePhoto from "@/components/ProfilePhoto";
import SubmitButton from "@/components/SubmitButton"; import SubmitButton from "@/components/SubmitButton";
import React from "react"; import React from "react";
import Checkbox from "@/components/Checkbox"; import Checkbox from "@/components/Checkbox";
import LinkPreview from "@/components/LinkPreview"; import LinkPreview from "@/components/LinkPreview";
import useLocalSettingsStore from "@/store/localSettings";
export default function Appearance() { export default function Appearance() {
const { theme, setTheme } = useTheme(); const { updateSettings } = useLocalSettingsStore();
const submit = async () => { const submit = async () => {
setSubmitLoader(true); setSubmitLoader(true);
@ -70,80 +65,46 @@ export default function Appearance() {
<SettingsLayout> <SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Appearance</p> <p className="capitalize text-3xl font-thin inline">Appearance</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-5">
<div> <div>
<p className="mb-3">Select Theme</p> <p className="mb-3">Select Theme</p>
<div className="flex gap-3 w-full"> <div className="flex gap-3 w-full">
<div <div
className={`w-full text-center outline-solid outline-sky-100 outline dark:outline-neutral-700 h-40 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${ className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-40 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-black ${
theme === "dark" localStorage.getItem("theme") === "dark"
? "dark:outline-sky-500 text-sky-500" ? "dark:outline-primary text-primary"
: "text-white" : "text-white"
}`} }`}
onClick={() => setTheme("dark")} onClick={() => updateSettings({ theme: "dark" })}
> >
<FontAwesomeIcon icon={faMoon} className="w-1/2 h-1/2" /> <FontAwesomeIcon icon={faMoon} className="w-1/2 h-1/2" />
<p className="text-2xl">Dark Theme</p> <p className="text-2xl">Dark Theme</p>
{/* <hr className="my-3 outline-1 outline-sky-100 dark:outline-neutral-700" /> */} {/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
</div> </div>
<div <div
className={`w-full text-center outline-solid outline-sky-100 outline dark:outline-neutral-700 h-40 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-white ${ className={`w-full text-center outline-solid outline-neutral-content outline dark:outline-neutral-700 h-40 duration-100 rounded-md flex items-center justify-center cursor-pointer select-none bg-white ${
theme === "light" localStorage.getItem("theme") === "light"
? "outline-sky-500 text-sky-500" ? "outline-primary text-primary"
: "text-black" : "text-black"
}`} }`}
onClick={() => setTheme("light")} onClick={() => updateSettings({ theme: "light" })}
> >
<FontAwesomeIcon icon={faSun} className="w-1/2 h-1/2" /> <FontAwesomeIcon icon={faSun} className="w-1/2 h-1/2" />
<p className="text-2xl">Light Theme</p> <p className="text-2xl">Light Theme</p>
{/* <hr className="my-3 outline-1 outline-sky-100 dark:outline-neutral-700" /> */} {/* <hr className="my-3 outline-1 outline-neutral-content dark:outline-neutral-700" /> */}
</div> </div>
</div> </div>
</div> </div>
<div> {/* <SubmitButton
<div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="text-black dark:text-white truncate w-full pr-7 text-3xl font-thin">
Link Card
</p>
</div>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" />
<Checkbox
label="Display Icons"
state={user.displayLinkIcons}
onClick={() =>
setUser({ ...user, displayLinkIcons: !user.displayLinkIcons })
}
/>
{user.displayLinkIcons ? (
<Checkbox
label="Blurred"
className="pl-5 mt-1"
state={user.blurredFavicons}
onClick={() =>
setUser({ ...user, blurredFavicons: !user.blurredFavicons })
}
/>
) : undefined}
<p className="my-3">Preview:</p>
<LinkPreview
settings={{
blurredFavicons: user.blurredFavicons,
displayLinkIcons: user.displayLinkIcons,
}}
/>
</div>
<SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}
label="Save" label="Save"
className="mt-2 mx-auto lg:mx-0" className="mt-2 mx-auto lg:mx-0"
/> /> */}
</div> </div>
</SettingsLayout> </SettingsLayout>
); );

View File

@ -59,7 +59,7 @@ export default function Archive() {
<SettingsLayout> <SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Archive Settings</p> <p className="capitalize text-3xl font-thin inline">Archive Settings</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<p>Formats to Archive webpages:</p> <p>Formats to Archive webpages:</p>
<div className="p-3"> <div className="p-3">

View File

@ -13,10 +13,10 @@ export default function Billing() {
<SettingsLayout> <SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Billing Settings</p> <p className="capitalize text-3xl font-thin inline">Billing Settings</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<div className="w-full mx-auto flex flex-col gap-3 justify-between"> <div className="w-full mx-auto flex flex-col gap-3 justify-between">
<p className="text-md text-black dark:text-white"> <p className="text-md">
To manage/cancel your subscription, visit the{" "} To manage/cancel your subscription, visit the{" "}
<a <a
href={process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL} href={process.env.NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL}
@ -27,7 +27,7 @@ export default function Billing() {
. .
</p> </p>
<p className="text-md text-black dark:text-white"> <p className="text-md">
If you still need help or encountered any issues, feel free to reach If you still need help or encountered any issues, feel free to reach
out to us at:{" "} out to us at:{" "}
<a <a

View File

@ -7,7 +7,7 @@ import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
export default function Password() { export default function Delete() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [comment, setComment] = useState<string>(); const [comment, setComment] = useState<string>();
const [feedback, setFeedback] = useState<string>(); const [feedback, setFeedback] = useState<string>();
@ -54,12 +54,15 @@ export default function Password() {
return ( return (
<CenteredForm> <CenteredForm>
<div className="p-4 mx-auto relative flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 dark:border-neutral-700 bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100"> <div className="p-4 mx-auto relative flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 bg-base-200 rounded-2xl shadow-md border border-neutral-content">
<Link <Link
href="/settings/account" href="/settings/account"
className="absolute top-4 left-4 gap-1 items-center select-none cursor-pointer p-2 text-gray-500 dark:text-gray-300 rounded-md duration-100 hover:bg-slate-200 dark:hover:bg-neutral-700" className="absolute top-4 left-4 btn btn-ghost btn-square btn-sm"
> >
<FontAwesomeIcon icon={faChevronLeft} className="w-5 h-5" /> <FontAwesomeIcon
icon={faChevronLeft}
className="w-5 h-5 text-neutral"
/>
</Link> </Link>
<div className="flex items-center gap-2 w-full rounded-md h-8"> <div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="text-red-500 dark:text-red-500 truncate w-full text-3xl text-center"> <p className="text-red-500 dark:text-red-500 truncate w-full text-3xl text-center">
@ -67,7 +70,7 @@ export default function Password() {
</p> </p>
</div> </div>
<hr className="border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-0"></div>
<p> <p>
This will permanently delete all the Links, Collections, Tags, and This will permanently delete all the Links, Collections, Tags, and
@ -80,22 +83,21 @@ export default function Password() {
{process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED !== "true" ? ( {process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED !== "true" ? (
<div> <div>
<p className="mb-2 text-black dark:text-white"> <p className="mb-2">Confirm Your Password</p>
Confirm Your Password
</p>
<TextInput <TextInput
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••••••••" placeholder="••••••••••••••"
className="bg-base-100"
type="password" type="password"
/> />
</div> </div>
) : undefined} ) : undefined}
{process.env.NEXT_PUBLIC_STRIPE ? ( {process.env.NEXT_PUBLIC_STRIPE ? (
<fieldset className="border rounded-md p-2 border-sky-500"> <fieldset className="border rounded-md p-2 border-primary">
<legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-sky-500"> <legend className="px-3 py-1 text-sm sm:text-base border rounded-md border-primary">
<b>Optional</b>{" "} <b>Optional</b>{" "}
<i className="min-[390px]:text-sm text-xs"> <i className="min-[390px]:text-sm text-xs">
(but it really helps us improve!) (but it really helps us improve!)
@ -104,7 +106,7 @@ export default function Password() {
<label className="w-full flex min-[430px]:items-center items-start gap-2 mb-3 min-[430px]:flex-row flex-col"> <label className="w-full flex min-[430px]:items-center items-start gap-2 mb-3 min-[430px]:flex-row flex-col">
<p className="text-sm">Reason for cancellation:</p> <p className="text-sm">Reason for cancellation:</p>
<select <select
className="rounded-md p-1 border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100 dark:bg-neutral-950" className="rounded-md p-1 outline-none"
value={feedback} value={feedback}
onChange={(e) => setFeedback(e.target.value)} onChange={(e) => setFeedback(e.target.value)}
> >
@ -120,7 +122,7 @@ export default function Password() {
</select> </select>
</label> </label>
<div> <div>
<p className="text-sm mb-2 text-black dark:text-white"> <p className="text-sm mb-2">
More information (the more details, the more helpful it&apos;d More information (the more details, the more helpful it&apos;d
be) be)
</p> </p>
@ -129,7 +131,7 @@ export default function Password() {
value={comment} value={comment}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
placeholder="e.g. I needed a feature that..." placeholder="e.g. I needed a feature that..."
className="resize-none w-full rounded-md p-2 border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100 dark:bg-neutral-950" className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-100 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/> />
</div> </div>
</fieldset> </fieldset>

View File

@ -47,26 +47,28 @@ export default function Password() {
<SettingsLayout> <SettingsLayout>
<p className="capitalize text-3xl font-thin inline">Change Password</p> <p className="capitalize text-3xl font-thin inline">Change Password</p>
<hr className="my-3 border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-3"></div>
<p className="mb-3"> <p className="mb-3">
To change your password, please fill out the following. Your password To change your password, please fill out the following. Your password
should be at least 8 characters. should be at least 8 characters.
</p> </p>
<div className="w-full flex flex-col gap-2 justify-between"> <div className="w-full flex flex-col gap-2 justify-between">
<p className="text-black dark:text-white">New Password</p> <p>New Password</p>
<TextInput <TextInput
value={newPassword} value={newPassword}
className="bg-base-200"
onChange={(e) => setNewPassword1(e.target.value)} onChange={(e) => setNewPassword1(e.target.value)}
placeholder="••••••••••••••" placeholder="••••••••••••••"
type="password" type="password"
/> />
<p className="text-black dark:text-white">Confirm New Password</p> <p>Confirm New Password</p>
<TextInput <TextInput
value={newPassword2} value={newPassword2}
className="bg-base-200"
onChange={(e) => setNewPassword2(e.target.value)} onChange={(e) => setNewPassword2(e.target.value)}
placeholder="••••••••••••••" placeholder="••••••••••••••"
type="password" type="password"

View File

@ -30,12 +30,12 @@ export default function Subscribe() {
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14 process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
}-day free trial, cancel anytime!`} }-day free trial, cancel anytime!`}
> >
<div className="p-4 mx-auto flex flex-col gap-3 justify-between dark:border-neutral-700 max-w-[30rem] min-w-80 w-full bg-slate-50 dark:bg-neutral-800 rounded-2xl shadow-md border border-sky-100"> <div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
<p className="sm:text-3xl text-2xl text-center font-extralight"> <p className="sm:text-3xl text-2xl text-center font-extralight">
Subscribe to Linkwarden! Subscribe to Linkwarden!
</p> </p>
<hr className="border-1 border-sky-100 dark:border-neutral-700" /> <div className="divider my-0"></div>
<div> <div>
<p> <p>
@ -47,10 +47,10 @@ export default function Subscribe() {
</p> </p>
</div> </div>
<div className="flex text-white dark:text-black gap-3 border border-solid border-sky-100 dark:border-neutral-700 w-4/5 mx-auto p-1 rounded-xl relative"> <div className="flex text-white dark:text-black gap-3 border border-solid border-neutral-content w-4/5 mx-auto p-1 rounded-xl relative">
<button <button
onClick={() => setPlan(Plan.monthly)} onClick={() => setPlan(Plan.monthly)}
className={`w-full text-black dark:text-white duration-100 text-sm rounded-lg p-1 ${ className={`w-full duration-100 text-sm rounded-lg p-1 ${
plan === Plan.monthly plan === Plan.monthly
? "text-white bg-sky-700 dark:bg-sky-700" ? "text-white bg-sky-700 dark:bg-sky-700"
: "hover:opacity-80" : "hover:opacity-80"
@ -61,7 +61,7 @@ export default function Subscribe() {
<button <button
onClick={() => setPlan(Plan.yearly)} onClick={() => setPlan(Plan.yearly)}
className={`w-full text-black dark:text-white duration-100 text-sm rounded-lg p-1 ${ className={`w-full duration-100 text-sm rounded-lg p-1 ${
plan === Plan.yearly plan === Plan.yearly
? "text-white bg-sky-700 dark:bg-sky-700" ? "text-white bg-sky-700 dark:bg-sky-700"
: "hover:opacity-80" : "hover:opacity-80"
@ -77,15 +77,13 @@ export default function Subscribe() {
<div className="flex flex-col gap-2 justify-center items-center"> <div className="flex flex-col gap-2 justify-center items-center">
<p className="text-3xl"> <p className="text-3xl">
${plan === Plan.monthly ? "4" : "3"} ${plan === Plan.monthly ? "4" : "3"}
<span className="text-base text-gray-500 dark:text-gray-400"> <span className="text-base text-neutral">/mo</span>
/mo
</span>
</p> </p>
<p className="font-semibold"> <p className="font-semibold">
Billed {plan === Plan.monthly ? "Monthly" : "Yearly"} Billed {plan === Plan.monthly ? "Monthly" : "Yearly"}
</p> </p>
<fieldset className="w-full flex-col flex justify-evenly px-4 pb-4 pt-1 rounded-md border border-sky-100 dark:border-neutral-700"> <fieldset className="w-full flex-col flex justify-evenly px-4 pb-4 pt-1 rounded-md border border-neutral-content">
<legend className="w-fit font-extralight px-2 border border-sky-100 dark:border-neutral-700 rounded-md text-xl"> <legend className="w-fit font-extralight px-2 border border-neutral rounded-md text-xl">
Total Total
</legend> </legend>
@ -108,7 +106,7 @@ export default function Subscribe() {
<div <div
onClick={() => signOut()} onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-gray-500 dark:text-gray-400 font-semibold " className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
> >
Sign Out Sign Out
</div> </div>

View File

@ -4,7 +4,6 @@ import {
faCheck, faCheck,
faEllipsis, faEllipsis,
faHashtag, faHashtag,
faSort,
faXmark, faXmark,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -24,7 +23,6 @@ export default function Index() {
const { links } = useLinkStore(); const { links } = useLinkStore();
const { tags, updateTag, removeTag } = useTagStore(); const { tags, updateTag, removeTag } = useTagStore();
const [sortDropdown, setSortDropdown] = useState(false);
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
const [expandDropdown, setExpandDropdown] = useState(false); const [expandDropdown, setExpandDropdown] = useState(false);
@ -107,7 +105,7 @@ export default function Index() {
<div className="flex gap-2 items-end font-thin"> <div className="flex gap-2 items-end font-thin">
<FontAwesomeIcon <FontAwesomeIcon
icon={faHashtag} icon={faHashtag}
className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-sky-500 dark:text-sky-500" className="sm:w-8 sm:h-8 w-6 h-6 mt-2 text-primary"
/> />
{renameTag ? ( {renameTag ? (
<> <>
@ -115,50 +113,78 @@ export default function Index() {
<input <input
type="text" type="text"
autoFocus autoFocus
className="sm:text-4xl text-3xl capitalize text-black dark:text-white bg-transparent h-10 w-3/4 outline-none border-b border-b-sky-100 dark:border-b-neutral-700" className="sm:text-4xl text-3xl capitalize bg-transparent h-10 w-3/4 outline-none border-b border-b-neutral-content"
value={newTagName} value={newTagName}
onChange={(e) => setNewTagName(e.target.value)} onChange={(e) => setNewTagName(e.target.value)}
/> />
<div <div
onClick={() => submit()} onClick={() => submit()}
id="expand-dropdown" id="expand-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" className="btn btn-ghost btn-square btn-sm"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faCheck} icon={faCheck}
id="expand-dropdown" id="expand-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300" className="w-5 h-5 text-neutral"
/> />
</div> </div>
<div <div
onClick={() => cancelUpdateTag()} onClick={() => cancelUpdateTag()}
id="expand-dropdown" id="expand-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" className="btn btn-ghost btn-square btn-sm"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faXmark} icon={faXmark}
id="expand-dropdown" id="expand-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300" className="w-5 h-5 text-neutral"
/> />
</div> </div>
</form> </form>
</> </>
) : ( ) : (
<> <>
<p className="sm:text-4xl text-3xl capitalize text-black dark:text-white"> <p className="sm:text-4xl text-3xl capitalize">
{activeTag?.name} {activeTag?.name}
</p> </p>
<div className="relative"> <div className="relative">
<div <div className="dropdown dropdown-bottom font-normal">
onClick={() => setExpandDropdown(!expandDropdown)} <div
id="expand-dropdown" tabIndex={0}
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1" role="button"
> className="btn btn-ghost btn-sm btn-square text-neutral"
<FontAwesomeIcon >
icon={faEllipsis} <FontAwesomeIcon
id="expand-dropdown" icon={faEllipsis}
className="w-5 h-5 text-gray-500 dark:text-gray-300" title="More"
/> className="w-5 h-5"
/>
</div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-36 mt-1">
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setRenameTag(true);
}}
>
Rename Tag
</div>
</li>
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
remove();
}}
>
Remove Tag
</div>
</li>
</ul>
</div> </div>
{expandDropdown ? ( {expandDropdown ? (
@ -194,25 +220,7 @@ export default function Index() {
</div> </div>
<div className="relative"> <div className="relative">
<div <SortDropdown sortBy={sortBy} setSort={setSortBy} />
onClick={() => setSortDropdown(!sortDropdown)}
id="sort-dropdown"
className="inline-flex rounded-md cursor-pointer hover:bg-slate-200 hover:dark:bg-neutral-700 duration-100 p-1"
>
<FontAwesomeIcon
icon={faSort}
id="sort-dropdown"
className="w-5 h-5 text-gray-500 dark:text-gray-300"
/>
</div>
{sortDropdown ? (
<SortDropdown
sortBy={sortBy}
setSort={setSortBy}
toggleSortDropdown={() => setSortDropdown(!sortDropdown)}
/>
) : null}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5"> <div className="grid grid-cols-1 2xl:grid-cols-3 xl:grid-cols-2 gap-5">

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'url',
ALTER COLUMN "url" DROP NOT NULL;

View File

@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `blurredFavicons` on the `User` table. All the data in the column will be lost.
- You are about to drop the column `displayLinkIcons` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "blurredFavicons",
DROP COLUMN "displayLinkIcons";

View File

@ -45,8 +45,6 @@ model User {
archiveAsPDF Boolean @default(true) archiveAsPDF Boolean @default(true)
archiveAsWaybackMachine Boolean @default(false) archiveAsWaybackMachine Boolean @default(false)
isPrivate Boolean @default(false) isPrivate Boolean @default(false)
displayLinkIcons Boolean @default(true)
blurredFavicons Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now()) updatedAt DateTime @updatedAt @default(now())
} }
@ -103,12 +101,13 @@ model UsersAndCollections {
model Link { model Link {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
url String type String @default("url")
description String @default("") description String @default("")
pinnedBy User[] pinnedBy User[]
collection Collection @relation(fields: [collectionId], references: [id]) collection Collection @relation(fields: [collectionId], references: [id])
collectionId Int collectionId Int
tags Tag[] tags Tag[]
url String?
textContent String? textContent String?
screenshotPath String? screenshotPath String?
pdfPath String? pdfPath String?

View File

@ -93,6 +93,8 @@ const useLinkStore = create<LinkStore>()((set) => ({
}; };
} }
}); });
return data;
} }
return { ok: response.ok, data: data.response }; return { ok: response.ok, data: data.response };

View File

@ -1,23 +1,46 @@
import { create } from "zustand"; import { create } from "zustand";
type LocalSettings = { type LocalSettings = {
darkMode: boolean; theme: string;
}; };
type LocalSettingsStore = { type LocalSettingsStore = {
settings: LocalSettings; settings: LocalSettings;
updateSettings: (settings: LocalSettings) => void; updateSettings: (settings: LocalSettings) => void;
setSettings: () => void;
}; };
const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({ const useLocalSettingsStore = create<LocalSettingsStore>((set) => ({
settings: { settings: {
darkMode: false, theme: "",
}, },
updateSettings: async (newSettings) => { updateSettings: async (newSettings) => {
if (
newSettings.theme &&
newSettings.theme !== localStorage.getItem("theme")
) {
localStorage.setItem("theme", newSettings.theme);
const localTheme = localStorage.getItem("theme") || "";
document.querySelector("html")?.setAttribute("data-theme", localTheme);
}
set((state) => ({ settings: { ...state.settings, ...newSettings } })); set((state) => ({ settings: { ...state.settings, ...newSettings } }));
}, },
setSettings: async () => {
if (!localStorage.getItem("theme")) {
localStorage.setItem("theme", "dark");
}
const localTheme = localStorage.getItem("theme") || "";
set((state) => ({
settings: { ...state.settings, theme: localTheme },
}));
document.querySelector("html")?.setAttribute("data-theme", localTheme);
},
})); }));
export default useLocalSettingsStore; export default useLocalSettingsStore;
// TODO: Add Dark mode.

View File

@ -2,6 +2,31 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
--my-color: #fff;
--selection-color: #fff;
--selection-bg-color: #fff;
}
[data-theme="dark"] {
--my-color: #000;
color-scheme: dark;
--selection-color: #000000;
--selection-bg-color: #ffffff;
}
[data-theme="light"] {
--my-color: #ffabc8;
color-scheme: light;
--selection-color: #ffffff;
--selection-bg-color: #000000;
}
::selection {
background-color: var(--selection-bg-color);
color: var(--selection-color);
}
html, html,
body { body {
scroll-behavior: smooth; scroll-behavior: smooth;
@ -16,11 +41,6 @@ body {
scrollbar-width: none; scrollbar-width: none;
} }
::selection {
background-color: #0ea4e93c;
color: white;
}
.hyphens { .hyphens {
hyphens: auto; hyphens: auto;
} }
@ -49,7 +69,7 @@ body {
} }
.slide-up { .slide-up {
animation: slide-up-animation 70ms; animation: slide-up-animation 200ms;
} }
.slide-down { .slide-down {
@ -69,7 +89,7 @@ body {
@keyframes slide-up-animation { @keyframes slide-up-animation {
0% { 0% {
transform: translateY(15%); transform: translateY(5%);
opacity: 0; opacity: 0;
} }
100% { 100% {
@ -140,41 +160,25 @@ body {
} }
/* Theme */ /* Theme */
@layer base {
body {
@apply dark:bg-neutral-900 bg-white text-black dark:text-white;
}
}
/* react-select */
@layer components { @layer components {
.react-select-container .react-select__control { .react-select-container .react-select__control {
@apply dark:bg-neutral-950 bg-gray-50 dark:border-neutral-700 dark:hover:border-neutral-500; @apply bg-base-200 hover:border-neutral-content;
}
.react-select-container {
@apply dark:border-neutral-700;
} }
.react-select-container .react-select__menu { .react-select-container .react-select__menu {
@apply dark:bg-neutral-900 dark:border-neutral-700 border; @apply bg-base-100 border-neutral-content border rounded-md;
}
.react-select-container .react-select__option {
@apply dark:hover:bg-neutral-800;
} }
/*
.react-select-container .react-select__menu-list {
@apply h-20;
} */
.react-select-container .react-select__input-container, .react-select-container .react-select__input-container,
.react-select-container .react-select__single-value { .react-select-container .react-select__single-value {
@apply dark:text-white; @apply text-base-content;
} }
} }
.react-select__clear-indicator * {
display: none;
width: 0;
margin: 0;
padding: 0;
}
.primary-btn-gradient { .primary-btn-gradient {
box-shadow: inset 0px -10px 10px #0071b7; box-shadow: inset 0px -10px 10px #0071b7;
@ -245,13 +249,14 @@ body {
.reader-view code { .reader-view code {
padding: 0.15rem 0.4rem 0.15rem 0.4rem; padding: 0.15rem 0.4rem 0.15rem 0.4rem;
} }
[class="dark"] .reader-view code,
[class="dark"] .reader-view pre { [data-theme="dark"] .reader-view code,
[data-theme="dark"] .reader-view pre {
background-color: rgb(49, 49, 49); background-color: rgb(49, 49, 49);
border-radius: 8px; border-radius: 8px;
} }
[class="light"] .reader-view code, [data-theme="light"] .reader-view code,
[class="light"] .reader-view pre { [data-theme="light"] .reader-view pre {
background-color: rgb(230, 230, 230); background-color: rgb(230, 230, 230);
border-radius: 8px; border-radius: 8px;
} }
@ -281,3 +286,7 @@ body {
background-position: 0% 50%; background-position: 0% 50%;
} }
} }
.custom-file-input::file-selector-button {
cursor: pointer;
}

View File

@ -1,10 +1,44 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
const plugin = require("tailwindcss/plugin");
module.exports = { module.exports = {
darkMode: "class", daisyui: {
// daisyui: { themes: [
// themes: ["light", "dark"], {
// }, light: {
primary: "#0369a1",
secondary: "#0891b2",
accent: "#6d28d9",
neutral: "#6b7280",
"neutral-content": "#d1d5db",
"base-100": "#ffffff",
"base-200": "#f3f4f6",
"base-content": "#0a0a0a",
info: "#a5f3fc",
success: "#22c55e",
warning: "#facc15",
error: "#dc2626",
},
},
{
dark: {
primary: "#7dd3fc",
secondary: "#22d3ee",
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: [ content: [
"./app/**/*.{js,ts,jsx,tsx}", "./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}", "./pages/**/*.{js,ts,jsx,tsx}",
@ -14,6 +48,9 @@ module.exports = {
"./layouts/**/*.{js,ts,jsx,tsx}", "./layouts/**/*.{js,ts,jsx,tsx}",
], ],
plugins: [ plugins: [
// require("daisyui") require("daisyui"),
plugin(({ addVariant }) => {
addVariant("dark", '&[data-theme="dark"]');
}),
], ],
}; };

View File

@ -9,6 +9,7 @@ declare global {
STORAGE_FOLDER?: string; STORAGE_FOLDER?: string;
AUTOSCROLL_TIMEOUT?: string; AUTOSCROLL_TIMEOUT?: string;
RE_ARCHIVE_LIMIT?: string; RE_ARCHIVE_LIMIT?: string;
NEXT_PUBLIC_MAX_UPLOAD_SIZE?: string;
SPACES_KEY?: string; SPACES_KEY?: string;
SPACES_SECRET?: string; SPACES_SECRET?: string;

View File

@ -117,7 +117,14 @@ export type DeleteUserBody = {
}; };
export enum ArchivedFormat { export enum ArchivedFormat {
screenshot, png,
jpeg,
pdf, pdf,
readability, readability,
} }
export enum LinkType {
url,
pdf,
image,
}

View File

@ -1497,6 +1497,13 @@
dependencies: dependencies:
"@types/trusted-types" "*" "@types/trusted-types" "*"
"@types/formidable@^3.4.5":
version "3.4.5"
resolved "https://registry.yarnpkg.com/@types/formidable/-/formidable-3.4.5.tgz#8e45c053cac5868e2b71cc7410e2bd92872f6b9c"
integrity sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==
dependencies:
"@types/node" "*"
"@types/jsdom@^21.1.3": "@types/jsdom@^21.1.3":
version "21.1.3" version "21.1.3"
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.3.tgz#a88c5dc65703e1b10b2a7839c12db49662b43ff0" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.3.tgz#a88c5dc65703e1b10b2a7839c12db49662b43ff0"
@ -1766,6 +1773,11 @@ array.prototype.tosorted@^1.1.1:
es-shim-unscopables "^1.0.0" es-shim-unscopables "^1.0.0"
get-intrinsic "^1.1.3" get-intrinsic "^1.1.3"
asap@^2.0.0:
version "2.0.6"
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==
asn1@~0.2.3: asn1@~0.2.3:
version "0.2.6" version "0.2.6"
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
@ -2299,6 +2311,14 @@ detect-libc@^2.0.0, detect-libc@^2.0.1:
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==
dezalgo@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81"
integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==
dependencies:
asap "^2.0.0"
wrappy "1"
didyoumean@^1.2.2: didyoumean@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037"
@ -2836,6 +2856,15 @@ form-data@~2.3.2:
combined-stream "^1.0.6" combined-stream "^1.0.6"
mime-types "^2.1.12" mime-types "^2.1.12"
formidable@^3.5.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.1.tgz#9360a23a656f261207868b1484624c4c8d06ee1a"
integrity sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==
dependencies:
dezalgo "^1.0.4"
hexoid "^1.0.0"
once "^1.4.0"
fraction.js@^4.2.0: fraction.js@^4.2.0:
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950"
@ -3151,6 +3180,11 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
hexoid@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18"
integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==
hoist-non-react-statics@^3.3.1: hoist-non-react-statics@^3.3.1:
version "3.3.2" version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
@ -3825,11 +3859,6 @@ next-auth@^4.22.1:
preact-render-to-string "^5.1.19" preact-render-to-string "^5.1.19"
uuid "^8.3.2" uuid "^8.3.2"
next-themes@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45"
integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==
next@13.4.12: next@13.4.12:
version "13.4.12" version "13.4.12"
resolved "https://registry.yarnpkg.com/next/-/next-13.4.12.tgz#809b21ea0aabbe88ced53252c88c4a5bd5af95df" resolved "https://registry.yarnpkg.com/next/-/next-13.4.12.tgz#809b21ea0aabbe88ced53252c88c4a5bd5af95df"