Merge pull request #330 from linkwarden/feat/handle-files

Feat/handle files
This commit is contained in:
Daniel 2023-12-07 09:04:18 +03:30 committed by GitHub
commit 9cd165f2ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 1298 additions and 380 deletions

View File

@ -13,6 +13,7 @@ STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT= AUTOSCROLL_TIMEOUT=
NEXT_PUBLIC_DISABLE_REGISTRATION= NEXT_PUBLIC_DISABLE_REGISTRATION=
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

@ -65,7 +65,7 @@ export default function CollectionCard({ collection, className }: Props) {
return ( return (
<div className="relative"> <div className="relative">
<div className="dropdown dropdown-bottom dropdown-end absolute top-3 right-3 z-10"> <div className="dropdown dropdown-bottom dropdown-end absolute top-3 right-3 z-20">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
@ -121,7 +121,7 @@ export default function CollectionCard({ collection, className }: Props) {
{collectionOwner.id ? ( {collectionOwner.id ? (
<ProfilePhoto <ProfilePhoto
src={collectionOwner.image || undefined} src={collectionOwner.image || undefined}
dimensionClass="w-7 h-7" name={collectionOwner.name}
/> />
) : undefined} ) : undefined}
{collection.members {collection.members
@ -131,13 +131,14 @@ export default function CollectionCard({ collection, className }: Props) {
<ProfilePhoto <ProfilePhoto
key={i} key={i}
src={e.user.image ? e.user.image : undefined} src={e.user.image ? e.user.image : undefined}
name={e.user.name}
className="-ml-3" className="-ml-3"
/> />
); );
}) })
.slice(0, 3)} .slice(0, 3)}
{collection.members.length - 3 > 0 ? ( {collection.members.length - 3 > 0 ? (
<div className={`avatar placeholder -ml-3`}> <div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content"> <div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span> <span>+{collection.members.length - 3}</span>
</div> </div>

View File

@ -13,14 +13,9 @@ type Props = {
value?: number; value?: number;
} }
| undefined; | undefined;
id?: string;
}; };
export default function CollectionSelection({ export default function CollectionSelection({ onChange, defaultValue }: Props) {
onChange,
defaultValue,
id,
}: Props) {
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();
const router = useRouter(); const router = useRouter();
@ -49,7 +44,6 @@ export default function CollectionSelection({
return ( return (
<CreatableSelect <CreatableSelect
key={id || "key"}
isClearable={false} isClearable={false}
className="react-select-container" className="react-select-container"
classNamePrefix="react-select" classNamePrefix="react-select"

View File

@ -13,7 +13,11 @@ import Image from "next/image";
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 { faCalendarDays } from "@fortawesome/free-regular-svg-icons"; import {
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/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
@ -22,6 +26,8 @@ import unescapeString from "@/lib/client/unescapeString";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import EditLinkModal from "./ModalContent/EditLinkModal"; import EditLinkModal from "./ModalContent/EditLinkModal";
import DeleteLinkModal from "./ModalContent/DeleteLinkModal"; import DeleteLinkModal from "./ModalContent/DeleteLinkModal";
import ExpandedLink from "./ModalContent/ExpandedLink";
import PreservedFormatsModal from "./ModalContent/PreservedFormatsModal";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -63,7 +69,7 @@ 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];
@ -81,23 +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...");
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...");
@ -122,6 +111,8 @@ export default function LinkCard({ link, count, className }: Props) {
const [editLinkModal, setEditLinkModal] = useState(false); const [editLinkModal, setEditLinkModal] = useState(false);
const [deleteLinkModal, setDeleteLinkModal] = useState(false); const [deleteLinkModal, setDeleteLinkModal] = useState(false);
const [preservedFormatsModal, setPreservedFormatsModal] = useState(false);
const [expandedLink, setExpandedLink] = useState(false);
return ( return (
<div <div
@ -183,10 +174,11 @@ export default function LinkCard({ link, count, className }: Props) {
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
updateArchive(); setPreservedFormatsModal(true);
// updateArchive();
}} }}
> >
Refresh Link Preserved Formats
</div> </div>
</li> </li>
) : undefined} ) : undefined}
@ -208,41 +200,67 @@ export default function LinkCard({ link, count, className }: Props) {
</div> </div>
) : undefined} ) : undefined}
<div <Link
onClick={() => router.push("/links/" + link.id)} href={"/links/" + link.id}
className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-4" // onClick={
// () => router.push("/links/" + link.id)
// // setExpandedLink(true)
// }
className="flex flex-col justify-between cursor-pointer h-full w-full gap-1 p-3"
> >
{url && account.displayLinkIcons && ( {link.url && url ? (
<Image <Image
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${url.origin}&size=32`} src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
width={64} width={64}
height={64} height={64}
alt="" alt=""
className={`${ className={`absolute w-12 bg-white shadow rounded-md p-1 bottom-3 right-3 select-none z-10`}
account.blurredFavicons ? "blur-sm " : ""
} absolute w-12 duration-100 bg-white rounded-md p-1 bottom-5 right-5 select-none z-10`}
draggable="false" draggable="false"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
target.style.display = "none"; target.style.display = "none";
}} }}
/> />
)} ) : link.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 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"> <div className="flex items-baseline gap-1">
<p className="text-sm text-neutral">{count + 1}</p> <p className="text-sm text-neutral">{count + 1}</p>
<p className="text-lg truncate capitalize w-full pr-8"> <p className="text-lg truncate w-full pr-8">
{unescapeString(link.name || link.description)} {unescapeString(link.name || link.description) || shortendURL}
</p> </p>
</div> </div>
<Link
href={`/collections/${link.collection.id}`} {link.url ? (
<div
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.preventDefault();
window.open(link.url || "", "_blank");
}} }}
className="flex items-center gap-1 max-w-full w-fit my-1 hover:opacity-70 duration-100" 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
onClick={(e) => {
e.preventDefault();
router.push(`/collections/${link.collection.id}`);
}}
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100"
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faFolder} icon={faFolder}
@ -250,10 +268,14 @@ export default function LinkCard({ link, count, className }: Props) {
style={{ color: collection?.color }} style={{ color: collection?.color }}
/> />
<p className="truncate capitalize w-full">{collection?.name}</p> <p className="truncate capitalize w-full">{collection?.name}</p>
</Link> </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] ? ( {/* {link.tags[0] ? (
<div className="flex gap-3 items-center flex-wrap my-2 truncate relative"> <div className="flex gap-3 items-center flex-wrap mt-2 truncate relative">
<div className="flex gap-1 items-center flex-nowrap"> <div className="flex gap-1 items-center flex-nowrap">
{link.tags.map((e, i) => ( {link.tags.map((e, i) => (
<Link <Link
@ -262,34 +284,18 @@ export default function LinkCard({ link, count, className }: Props) {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
className="btn btn-xs btn-outline truncate max-w-[19rem]" className="btn btn-xs btn-ghost truncate max-w-[19rem]"
> >
{e.name} #{e.name}
</Link> </Link>
))} ))}
</div> </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 className="absolute w-1/2 top-0 bottom-0 right-0 bg-gradient-to-r from-transparent to-base-200 to-35%"></div>
</div> </div>
) : undefined} */} ) : (
<p className="text-xs mt-2 p-1 font-semibold italic">No Tags</p>
<Link )} */}
href={link.url || ""}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
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" />
<p className="truncate w-full">{shortendURL}</p>
</Link> </Link>
<div className="flex items-center gap-1 text-neutral">
<FontAwesomeIcon icon={faCalendarDays} className="w-4 h-4" />
<p>{formattedDate}</p>
</div>
</div>
</div>
</div>
{editLinkModal ? ( {editLinkModal ? (
<EditLinkModal <EditLinkModal
onClose={() => setEditLinkModal(false)} onClose={() => setEditLinkModal(false)}
@ -302,6 +308,15 @@ export default function LinkCard({ link, count, className }: Props) {
activeLink={link} activeLink={link}
/> />
) : undefined} ) : undefined}
{preservedFormatsModal ? (
<PreservedFormatsModal
onClose={() => setPreservedFormatsModal(false)}
activeLink={link}
/>
) : undefined}
{/* {expandedLink ? (
<ExpandedLink onClose={() => setExpandedLink(false)} link={link} />
) : undefined} */}
</div> </div>
); );
} }

View File

@ -11,12 +11,14 @@ type Props = {
export default function Modal({ toggleModal, className, children }: Props) { export default function Modal({ toggleModal, className, children }: Props) {
return ( return (
<div className="overflow-y-auto 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"> <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 <ClickAwayHandler
onClickOutside={toggleModal} onClickOutside={toggleModal}
className={`m-auto w-11/12 max-w-2xl ${className || ""}`} className={`w-full mt-auto sm:m-auto sm:w-11/12 sm:max-w-2xl ${
className || ""
}`}
> >
<div className="slide-up m-auto relative border-neutral-content rounded-2xl border-solid border shadow-2xl p-5 bg-base-100"> <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 <div
onClick={toggleModal as MouseEventHandler<HTMLDivElement>} onClick={toggleModal as MouseEventHandler<HTMLDivElement>}
className="absolute top-3 right-3 btn btn-sm outline-none btn-circle btn-ghost" className="absolute top-3 right-3 btn btn-sm outline-none btn-circle btn-ghost"

View File

@ -92,7 +92,7 @@ export default function PreservedFormats() {
{link?.screenshotPath && link?.screenshotPath !== "pending" ? ( {link?.screenshotPath && link?.screenshotPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-neutral-content 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-primary 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>
@ -131,7 +131,7 @@ export default function PreservedFormats() {
{link?.pdfPath && link.pdfPath !== "pending" ? ( {link?.pdfPath && link.pdfPath !== "pending" ? (
<div className="flex justify-between items-center pr-1 border border-neutral-content 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-primary 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>
@ -166,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" &&
@ -176,12 +176,14 @@ export default function PreservedFormats() {
}`} }`}
onClick={() => updateArchive()} onClick={() => updateArchive()}
> >
<div>
<p>Update Preserved Formats</p> <p>Update Preserved Formats</p>
<p className="text-xs">(Refresh Link)</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+:|^)\/\//,
"" ""
)}`} )}`}

View File

@ -64,10 +64,12 @@ export default function DeleteCollectionModal({
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl mb-5 font-thin text-red-500"> <p className="text-xl font-thin text-red-500">
{permissions === true ? "Delete" : "Leave"} Collection {permissions === true ? "Delete" : "Leave"} Collection
</p> </p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{permissions === true ? ( {permissions === true ? (
<> <>

View File

@ -11,6 +11,7 @@ import Link from "next/link";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLink, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { faLink, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import Modal from "../Modal"; import Modal from "../Modal";
import { useRouter } from "next/router";
type Props = { type Props = {
onClose: Function; onClose: Function;
@ -21,17 +22,11 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink); useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
let shortendURL;
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
}
const { removeLink } = useLinkStore(); const { removeLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
useEffect(() => { useEffect(() => {
setLink(activeLink); setLink(activeLink);
}, []); }, []);
@ -45,12 +40,19 @@ export default function DeleteLinkModal({ onClose, activeLink }: Props) {
response.ok && toast.success(`Link Deleted.`); response.ok && toast.success(`Link Deleted.`);
if (router.pathname.startsWith("/links/[id]")) {
router.push("/dashboard");
}
onClose(); onClose();
}; };
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl mb-5 font-thin text-red-500">Delete Link</p> <p className="text-xl font-thin text-red-500">Delete Link</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p>Are you sure you want to delete this Link?</p> <p>Are you sure you want to delete this Link?</p>

View File

@ -49,7 +49,9 @@ export default function EditCollectionModal({
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl mb-5 font-thin">Edit Collection Info</p> <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 gap-3">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
@ -108,7 +110,10 @@ export default function EditCollectionModal({
</div> </div>
</div> </div>
<button className="btn btn-accent w-fit ml-auto" onClick={submit}> <button
className="btn btn-accent text-white w-fit ml-auto"
onClick={submit}
>
Save Save
</button> </button>
</div> </div>

View File

@ -95,10 +95,12 @@ export default function EditCollectionSharingModal({
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl font-thin mb-5"> <p className="text-xl font-thin">
{permissions === true ? "Share and Collaborate" : "Team"} {permissions === true ? "Share and Collaborate" : "Team"}
</p> </p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{permissions === true && ( {permissions === true && (
<div> <div>
@ -178,7 +180,7 @@ export default function EditCollectionSharingModal({
setMemberState setMemberState
) )
} }
className="btn btn-primary text-white btn-square" className="btn btn-accent text-white btn-square btn-sm h-10 w-10"
> >
<FontAwesomeIcon icon={faUserPlus} className="w-5 h-5" /> <FontAwesomeIcon icon={faUserPlus} className="w-5 h-5" />
</div> </div>
@ -201,7 +203,7 @@ export default function EditCollectionSharingModal({
<div className="flex flex-col gap-3 rounded-md"> <div className="flex flex-col gap-3 rounded-md">
<div <div
className="relative border px-2 rounded-xl border-neutral-content bg-base-200 flex min-h-[7rem] sm:min-h-[5rem] gap-2 justify-between" 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.`} 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">
@ -209,6 +211,7 @@ export default function EditCollectionSharingModal({
src={ src={
collectionOwner.image ? collectionOwner.image : undefined collectionOwner.image ? collectionOwner.image : undefined
} }
name={collectionOwner.name}
/> />
<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">
@ -257,6 +260,7 @@ export default function EditCollectionSharingModal({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProfilePhoto <ProfilePhoto
src={e.user.image ? e.user.image : undefined} src={e.user.image ? e.user.image : undefined}
name={e.user.name}
/> />
<div> <div>
<p className="text-sm font-bold">{e.user.name}</p> <p className="text-sm font-bold">{e.user.name}</p>
@ -323,7 +327,7 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<span <span
className={`peer-checked:bg-primary text-sm ${ className={`peer-checked:bg-primary peer-checked:text-primary-content text-sm ${
permissions === true permissions === true
? "hover:bg-neutral-content duration-100" ? "hover:bg-neutral-content duration-100"
: "" : ""
@ -368,7 +372,7 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<span <span
className={`peer-checked:bg-primary text-sm ${ className={`peer-checked:bg-primary peer-checked:text-primary-content text-sm ${
permissions === true permissions === true
? "hover:bg-neutral-content duration-100" ? "hover:bg-neutral-content duration-100"
: "" : ""
@ -413,7 +417,7 @@ export default function EditCollectionSharingModal({
}} }}
/> />
<span <span
className={`peer-checked:bg-primary text-sm ${ className={`peer-checked:bg-primary peer-checked:text-primary-content text-sm ${
permissions === true permissions === true
? "hover:bg-neutral-content duration-100" ? "hover:bg-neutral-content duration-100"
: "" : ""
@ -434,7 +438,7 @@ export default function EditCollectionSharingModal({
{permissions === true && ( {permissions === true && (
<button <button
className="btn btn-accent w-fit ml-auto mt-3" className="btn btn-accent text-white w-fit ml-auto mt-3"
onClick={submit} onClick={submit}
> >
Save Save

View File

@ -24,7 +24,7 @@ export default function EditLinkModal({ onClose, activeLink }: 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);
} }
@ -78,8 +78,11 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl mb-5 font-thin">Edit Link</p> <p className="text-xl font-thin">Edit Link</p>
<div className="divider mb-3 mt-1"></div>
{link.url ? (
<Link <Link
href={link.url} href={link.url}
className="truncate text-neutral flex gap-2 mb-5 w-fit max-w-full" className="truncate text-neutral flex gap-2 mb-5 w-fit max-w-full"
@ -92,6 +95,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
/> />
<p>{shortendURL}</p> <p>{shortendURL}</p>
</Link> </Link>
) : undefined}
<div className="w-full"> <div className="w-full">
<p className="mb-2">Name</p> <p className="mb-2">Name</p>
@ -155,7 +159,7 @@ export default function EditLinkModal({ onClose, activeLink }: Props) {
</div> </div>
<div className="flex justify-end items-center mt-5"> <div className="flex justify-end items-center mt-5">
<button className="btn btn-accent" onClick={submit}> <button className="btn btn-accent text-white" onClick={submit}>
Save Save
</button> </button>
</div> </div>

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

@ -54,7 +54,9 @@ export default function NewCollectionModal({ onClose }: Props) {
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl mb-5 font-thin">Create a New Collection</p> <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 gap-3">
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
@ -113,7 +115,10 @@ export default function NewCollectionModal({ onClose }: Props) {
</div> </div>
</div> </div>
<button className="btn btn-accent w-fit ml-auto" onClick={submit}> <button
className="btn btn-accent text-white w-fit ml-auto"
onClick={submit}
>
Create Collection Create Collection
</button> </button>
</div> </div>

View File

@ -23,6 +23,7 @@ export default function NewLinkModal({ onClose }: Props) {
name: "", name: "",
url: "", url: "",
description: "", description: "",
type: "url",
tags: [], tags: [],
screenshotPath: "", screenshotPath: "",
pdfPath: "", pdfPath: "",
@ -32,7 +33,7 @@ export default function NewLinkModal({ onClose }: Props) {
name: "", name: "",
ownerId: data?.user.id as number, ownerId: data?.user.id as number,
}, },
}; } as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] = const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial); useState<LinkIncludingShortenedCollectionAndTags>(initial);
@ -40,8 +41,6 @@ export default function NewLinkModal({ onClose }: Props) {
const { addLink } = useLinkStore(); const { addLink } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [resetCollectionSelection, setResetCollectionSelection] = useState("");
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
const router = useRouter(); const router = useRouter();
@ -65,10 +64,6 @@ export default function NewLinkModal({ onClose }: Props) {
}; };
useEffect(() => { useEffect(() => {
setResetCollectionSelection(Date.now().toString());
console.log(link);
setOptionsExpanded(false);
if (router.query.id) { if (router.query.id) {
const currentCollection = collections.find( const currentCollection = collections.find(
(e) => e.id == Number(router.query.id) (e) => e.id == Number(router.query.id)
@ -122,12 +117,15 @@ export default function NewLinkModal({ onClose }: Props) {
return ( return (
<Modal toggleModal={onClose}> <Modal toggleModal={onClose}>
<p className="text-xl mb-5 font-thin">Create a New Link</p> <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="grid grid-flow-row-dense sm:grid-cols-5 gap-3">
<div className="sm:col-span-3 col-span-5"> <div className="sm:col-span-3 col-span-5">
<p className="mb-2">Link</p> <p className="mb-2">Link</p>
<TextInput <TextInput
value={link.url} value={link.url || ""}
onChange={(e) => setLink({ ...link, url: e.target.value })} onChange={(e) => setLink({ ...link, url: e.target.value })}
placeholder="e.g. http://example.com/" placeholder="e.g. http://example.com/"
className="bg-base-200" className="bg-base-200"
@ -142,7 +140,6 @@ export default function NewLinkModal({ onClose }: Props) {
label: link.collection.name, label: link.collection.name,
value: link.collection.id, value: link.collection.id,
}} }}
id={resetCollectionSelection}
/> />
) : null} ) : null}
</div> </div>
@ -195,7 +192,7 @@ export default function NewLinkModal({ onClose }: Props) {
<p>{optionsExpanded ? "Hide" : "More"} Options</p> <p>{optionsExpanded ? "Hide" : "More"} Options</p>
</div> </div>
<button className="btn btn-accent" onClick={submit}> <button className="btn btn-accent text-white" onClick={submit}>
Create Link Create Link
</button> </button>
</div> </div>

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

@ -14,6 +14,7 @@ import useLocalSettingsStore from "@/store/localSettings";
import NewLinkModal from "./ModalContent/NewLinkModal"; import NewLinkModal from "./ModalContent/NewLinkModal";
import NewCollectionModal from "./ModalContent/NewCollectionModal"; import NewCollectionModal from "./ModalContent/NewCollectionModal";
import Link from "next/link"; import Link from "next/link";
import UploadFileModal from "./ModalContent/UploadFileModal";
export default function Navbar() { export default function Navbar() {
const { settings, updateSettings } = useLocalSettingsStore(); const { settings, updateSettings } = useLocalSettingsStore();
@ -48,6 +49,7 @@ export default function Navbar() {
const [newLinkModal, setNewLinkModal] = useState(false); const [newLinkModal, setNewLinkModal] = useState(false);
const [newCollectionModal, setNewCollectionModal] = useState(false); const [newCollectionModal, setNewCollectionModal] = useState(false);
const [uploadFileModal, setUploadFileModal] = useState(false);
return ( return (
<div className="flex justify-between gap-2 items-center px-4 py-2 border-solid border-b-neutral-content border-b"> <div className="flex justify-between gap-2 items-center px-4 py-2 border-solid border-b-neutral-content border-b">
@ -88,6 +90,18 @@ export default function Navbar() {
New Link New Link
</div> </div>
</li> </li>
{/* <li>
<div
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setUploadFileModal(true);
}}
tabIndex={0}
role="button"
>
Upload File
</div>
</li> */}
<li> <li>
<div <div
onClick={() => { onClick={() => {
@ -163,6 +177,9 @@ export default function Navbar() {
{newCollectionModal ? ( {newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} /> <NewCollectionModal onClose={() => setNewCollectionModal(false)} />
) : undefined} ) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
) : undefined}
</div> </div>
); );
} }

View File

@ -31,7 +31,7 @@ export default function ProfilePhoto({
return !image ? ( return !image ? (
<div <div
className={`avatar placeholder ${className || ""} ${ className={`avatar drop-shadow-md placeholder ${className || ""} ${
dimensionClass || "w-8 h-8 " dimensionClass || "w-8 h-8 "
}`} }`}
> >

View File

@ -57,9 +57,9 @@ 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="btn btn-xs btn-outline truncate max-w-[19rem]" className="btn btn-xs btn-ghost truncate max-w-[19rem]"
> >
{e.name} #{e.name}
</Link> </Link>
))} ))}
</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();

View File

@ -21,7 +21,7 @@ export default function SubmitButton({
return ( return (
<button <button
type={type ? type : undefined} type={type ? type : undefined}
className={`btn btn-primary text-white tracking-wider w-fit flex items-center gap-2 ${ className={`btn btn-accent text-white tracking-wider w-fit flex items-center gap-2 ${
className || "" className || ""
}`} }`}
onClick={() => { onClick={() => {

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,17 +78,31 @@ 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-neutral rounded-md duration-100 hover:bg-neutral-content" 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"
@ -91,75 +110,49 @@ export default function LinkLayout({ children }: Props) {
<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-neutral rounded-md duration-100"
> >
<FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" /> <FontAwesomeIcon icon={faChevronLeft} className="w-4 h-4" />
Back{" "}
<span className="hidden sm:inline-block">
to{" "}
<span className="capitalize"> <span className="capitalize">
{router.pathname.startsWith("/public") {router.pathname.startsWith("/public")
? linkCollection?.name || link?.collection?.name ? linkCollection?.name || link?.collection?.name
: "Dashboard"} : "Dashboard"}
</span> </span>
</span> </Link>
</div>
<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-neutral" 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-neutral" 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-neutral" className="w-4 h-4 text-neutral"
/> />
</div> </div>
) : undefined} ) : undefined}
@ -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

@ -1,6 +1,6 @@
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 urlHandler from "@/lib/api/urlHandler"; 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";
@ -75,7 +75,6 @@ export default async function postLink(
name: link.name, name: link.name,
description, description,
type: linkType, type: linkType,
readabilityPath: "pending",
collection: { collection: {
connectOrCreate: { connectOrCreate: {
where: { where: {
@ -118,11 +117,33 @@ export default async function postLink(
? urlHandler(newLink.id, newLink.url, userId) ? urlHandler(newLink.id, newLink.url, userId)
: undefined; : undefined;
linkType === "pdf" ? pdfHandler(newLink.id, newLink.url) : undefined; newLink.url && linkType === "pdf"
? pdfHandler(newLink.id, newLink.url)
: undefined;
linkType === "image" newLink.url && linkType === "image"
? imageHandler(newLink.id, newLink.url, imageExtension) ? imageHandler(newLink.id, newLink.url, imageExtension)
: undefined; : 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 };
} }

View File

@ -37,6 +37,23 @@ export default async function urlHandler(
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

@ -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,6 +38,7 @@
"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",

View File

@ -47,7 +47,7 @@ export default function App({
reverseOrder={false} reverseOrder={false}
toastOptions={{ toastOptions={{
className: className:
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white", "border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
}} }}
/> />
<Component {...pageProps} /> <Component {...pageProps} />

View File

@ -3,21 +3,35 @@ 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.png) suffix = ".png"; if (format === ArchivedFormat.png) suffix = ".png";
else if (format === ArchivedFormat.jpeg) suffix = ".jpeg"; 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." });
if (req.method === "GET") {
const token = await getToken({ req }); const token = await getToken({ req });
const userId = token?.id; const userId = token?.id;
@ -49,3 +63,82 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.send(file); 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

@ -111,7 +111,10 @@ export default function Index() {
onClick={() => setEditCollectionSharingModal(true)} onClick={() => setEditCollectionSharingModal(true)}
> >
{collectionOwner.id ? ( {collectionOwner.id ? (
<ProfilePhoto src={collectionOwner.image || undefined} /> <ProfilePhoto
src={collectionOwner.image || undefined}
name={collectionOwner.name}
/>
) : undefined} ) : undefined}
{activeCollection.members {activeCollection.members
.sort((a, b) => (a.userId as number) - (b.userId as number)) .sort((a, b) => (a.userId as number) - (b.userId as number))
@ -121,19 +124,20 @@ export default function Index() {
key={i} key={i}
src={e.user.image ? e.user.image : undefined} src={e.user.image ? e.user.image : undefined}
className="-ml-3" className="-ml-3"
name={e.user.name}
/> />
); );
}) })
.slice(0, 3)} .slice(0, 3)}
{activeCollection.members.length - 3 > 0 ? ( {activeCollection.members.length - 3 > 0 ? (
<div className={`avatar placeholder -ml-3`}> <div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content"> <div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{activeCollection.members.length - 3}</span> <span>+{activeCollection.members.length - 3}</span>
</div> </div>
</div> </div>
) : null} ) : null}
</div> </div>
<p className="text-neutral text-xs"> <p className="text-neutral text-sm font-semibold">
By {collectionOwner.name} By {collectionOwner.name}
{activeCollection.members.length > 0 {activeCollection.members.length > 0
? ` and ${activeCollection.members.length} others` ? ` and ${activeCollection.members.length} others`

View File

@ -44,35 +44,6 @@ export default function Collections() {
<p>Collections you own</p> <p>Collections you own</p>
</div> </div>
</div> </div>
<div className="relative mt-2">
<div className="dropdown dropdown-bottom">
<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-[20] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
<li>
<div
role="button"
tabIndex={0}
onClick={() => {
(document?.activeElement as HTMLElement)?.blur();
setNewCollectionModal(true);
}}
>
New Collection
</div>
</li>
</ul>
</div>
</div>
</div> </div>
<div className="relative mt-2"> <div className="relative mt-2">

View File

@ -13,7 +13,11 @@ import unescapeString from "@/lib/client/unescapeString";
import isValidUrl from "@/lib/shared/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"; import useLocalSettingsStore from "@/store/localSettings";
@ -118,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]
@ -145,19 +149,15 @@ export default function Index() {
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 ${
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-neutral-content 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`}
@ -165,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-base-100 bg-base-100 aspect-square" className="bg-white shadow rounded-md p-1 select-none mt-1"
draggable="false" draggable="false"
onLoad={(e) => { onLoad={(e) => {
try { try {
@ -184,40 +184,27 @@ export default function Index() {
}} }}
/> />
)} )}
<div className="flex flex-col">
<div className="flex gap-2 text-sm text-neutral"> <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 ? (
<>
<p></p>
<Link <Link
href={link?.url || ""} href={link?.url || ""}
title={link?.url} title={link?.url}
target="_blank" target="_blank"
className="hover:opacity-60 duration-100 break-all" className="hover:opacity-60 duration-100 break-all text-sm flex items-center gap-1 text-neutral w-fit"
> >
<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">
<p className="capitalize text-2xl sm:text-3xl font-thin">
{unescapeString(link?.name || link?.description || "")}
</p>
<div className="flex gap-1 items-center flex-wrap"> <div className="flex gap-1 items-center flex-wrap">
<Link <Link
href={`/collections/${link?.collection.id}`} href={`/collections/${link?.collection.id}`}
@ -239,21 +226,34 @@ export default function Index() {
<Link key={i} href={`/tags/${e.id}`} className="z-10"> <Link key={i} href={`/tags/${e.id}`} className="z-10">
<p <p
title={e.name} title={e.name}
className="btn btn-xs btn-outline truncate max-w-[19rem]" className="btn btn-xs btn-ghost truncate max-w-[19rem]"
> >
{e.name} #{e.name}
</p> </p>
</Link> </Link>
))} ))}
</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>
<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 || "") || "",
}} }}

View File

@ -145,7 +145,7 @@ export default function PublicCollections() {
{collectionOwner.id ? ( {collectionOwner.id ? (
<ProfilePhoto <ProfilePhoto
src={collectionOwner.image || undefined} src={collectionOwner.image || undefined}
dimensionClass="w-7 h-7" name={collectionOwner.name}
/> />
) : undefined} ) : undefined}
{collection.members {collection.members
@ -156,12 +156,13 @@ export default function PublicCollections() {
key={i} key={i}
src={e.user.image ? e.user.image : undefined} src={e.user.image ? e.user.image : undefined}
className="-ml-3" className="-ml-3"
name={e.user.name}
/> />
); );
}) })
.slice(0, 3)} .slice(0, 3)}
{collection.members.length - 3 > 0 ? ( {collection.members.length - 3 > 0 ? (
<div className={`avatar placeholder -ml-3`}> <div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content"> <div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span> <span>+{collection.members.length - 3}</span>
</div> </div>
@ -169,7 +170,7 @@ export default function PublicCollections() {
) : null} ) : null}
</div> </div>
<p className="text-neutral text-xs"> <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`

View File

@ -239,14 +239,10 @@ export default function Index() {
<Link <Link
key={i} key={i}
href={"/public/collections/20?q=" + e.name} href={"/public/collections/20?q=" + e.name}
className="z-10"
>
<p
title={e.name} title={e.name}
className="btn btn-xs btn-outline truncate max-w-[19rem]" className="z-10 btn btn-xs btn-ghost truncate max-w-[19rem]"
> >
{e.name} #{e.name}
</p>
</Link> </Link>
))} ))}
</div> </div>

View File

@ -99,46 +99,12 @@ export default function Appearance() {
</div> </div>
</div> </div>
<div> {/* <SubmitButton
<div className="flex items-center gap-2 w-full rounded-md h-8">
<p className="truncate w-full pr-7 text-3xl font-thin">Link Card</p>
</div>
<div className="divider my-3"></div>
<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

@ -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())
} }

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

@ -286,3 +286,7 @@ body {
background-position: 0% 50%; background-position: 0% 50%;
} }
} }
.custom-file-input::file-selector-button {
cursor: pointer;
}

View File

@ -6,9 +6,9 @@ module.exports = {
themes: [ themes: [
{ {
light: { light: {
primary: "#0284c7", primary: "#0369a1",
secondary: "#0891b2", secondary: "#0891b2",
accent: "#6366f1", accent: "#6d28d9",
neutral: "#6b7280", neutral: "#6b7280",
"neutral-content": "#d1d5db", "neutral-content": "#d1d5db",
"base-100": "#ffffff", "base-100": "#ffffff",
@ -22,9 +22,9 @@ module.exports = {
}, },
{ {
dark: { dark: {
primary: "#38bdf8", primary: "#7dd3fc",
secondary: "#22d3ee", secondary: "#22d3ee",
accent: "#6366f1", accent: "#6d28d9",
neutral: "#9ca3af", neutral: "#9ca3af",
"neutral-content": "#404040", "neutral-content": "#404040",
"base-100": "#171717", "base-100": "#171717",

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

@ -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"