upload preview functionality

This commit is contained in:
daniel31x13 2024-09-04 22:19:40 -04:00
parent e9072bba51
commit 3de8872f26
12 changed files with 301 additions and 212 deletions

View File

@ -39,32 +39,10 @@ const IconPopover = ({
onClose={() => onClose()}
className={clsx(
className,
"fade-in bg-base-200 border border-neutral-content p-2 h-44 w-[22.5rem] rounded-lg shadow-md"
"fade-in bg-base-200 border border-neutral-content p-2 w-[22.5rem] rounded-lg shadow-md"
)}
>
<div className="flex gap-2 h-full w-full">
<div className="flex flex-col gap-2 h-full w-fit color-picker">
<div
className="btn btn-ghost btn-xs"
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
>
{t("reset")}
</div>
<select
className="border border-neutral-content bg-base-100 focus:outline-none focus:border-primary duration-100 w-full rounded-md h-7"
value={weight}
onChange={(e) => setWeight(e.target.value)}
>
<option value="regular">{t("regular")}</option>
<option value="thin">{t("thin")}</option>
<option value="light">{t("light_icon")}</option>
<option value="bold">{t("bold")}</option>
<option value="fill">{t("fill")}</option>
<option value="duotone">{t("duotone")}</option>
</select>
<HexColorPicker color={color} onChange={(e) => setColor(e)} />
</div>
<div className="flex flex-col gap-2 w-full">
<TextInput
className="p-2 rounded w-full h-7 text-sm"
@ -73,7 +51,7 @@ const IconPopover = ({
onChange={(e) => setQuery(e.target.value)}
/>
<div className="grid grid-cols-4 gap-1 w-full overflow-y-auto h-32 border border-neutral-content bg-base-100 rounded-md p-2">
<div className="grid grid-cols-6 gap-1 w-full overflow-y-auto h-44 border border-neutral-content bg-base-100 rounded-md p-2">
<IconGrid
query={query}
color={color}
@ -82,6 +60,79 @@ const IconPopover = ({
setIconName={setIconName}
/>
</div>
<div className="flex gap-2 color-picker w-full">
<HexColorPicker color={color} onChange={(e) => setColor(e)} />
<div className="grid grid-cols-2 gap-1 text-sm">
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="regular"
checked={weight === "regular"}
onChange={() => setWeight("regular")}
/>
{t("regular")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="thin"
checked={weight === "thin"}
onChange={() => setWeight("thin")}
/>
{t("thin")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="light"
checked={weight === "light"}
onChange={() => setWeight("light")}
/>
{t("light_icon")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="bold"
checked={weight === "bold"}
onChange={() => setWeight("bold")}
/>
{t("bold")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="fill"
checked={weight === "fill"}
onChange={() => setWeight("fill")}
/>
{t("fill")}
</label>
<label className="flex items-center cursor-pointer">
<input
type="radio"
className="radio radio-primary mr-2"
value="duotone"
checked={weight === "duotone"}
onChange={() => setWeight("duotone")}
/>
{t("duotone")}
</label>
</div>
</div>
<div
className="btn btn-neutral btn-sm mt-2 w-fit mx-auto"
onClick={reset as React.MouseEventHandler<HTMLDivElement>}
>
{t("reset_defaults")}
</div>
</div>
</div>
</Popover>

View File

@ -4,7 +4,6 @@ import {
ArchivedFormat,
} from "@/types/global";
import Link from "next/link";
import { useSession } from "next-auth/react";
import {
pdfAvailable,
readabilityAvailable,
@ -17,7 +16,11 @@ import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import { useGetLink, useUpdateLink } from "@/hooks/store/links";
import {
useGetLink,
useUpdateLink,
useUpdatePreview,
} from "@/hooks/store/links";
import LinkIcon from "./LinkViews/LinkComponents/LinkIcon";
import CopyButton from "./CopyButton";
import { useRouter } from "next/router";
@ -31,6 +34,7 @@ import TagSelection from "./InputSelect/TagSelection";
import unescapeString from "@/lib/client/unescapeString";
import IconPopover from "./IconPopover";
import TextInput from "./TextInput";
import usePermissions from "@/hooks/usePermissions";
type Props = {
className?: string;
@ -50,8 +54,13 @@ export default function LinkDetails({
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
useEffect(() => {
setLink(activeLink);
}, [activeLink]);
const permissions = usePermissions(link.collection.id as number);
const { t } = useTranslation();
const session = useSession();
const getLink = useGetLink();
const { data: user = {} } = useUser();
@ -140,13 +149,14 @@ export default function LinkDetails({
clearInterval(interval);
}
};
}, [link?.monolith]);
}, [link.monolith]);
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const updateLink = useUpdateLink();
const updatePreview = useUpdatePreview();
const submit = async (e?: any) => {
e?.preventDefault();
@ -169,7 +179,6 @@ export default function LinkDetails({
} else {
toast.success(t("updated"));
setMode && setMode("view");
console.log(data);
setLink(data);
}
},
@ -208,7 +217,7 @@ export default function LinkDetails({
>
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""
@ -227,7 +236,7 @@ export default function LinkDetails({
<div className="duration-100 h-40 skeleton rounded-b-none"></div>
)}
{!standalone && (
{!standalone && (permissions === true || permissions?.canUpdate) && (
<div className="absolute top-0 bottom-0 left-0 right-0 opacity-0 group-hover:opacity-100 duration-100 flex justify-end items-end">
<label className="btn btn-xs mb-2 mr-3 opacity-50 hover:opacity-100">
{t("upload_preview_image")}
@ -238,37 +247,26 @@ export default function LinkDetails({
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
const load = toast.loading(t("updating"));
const load = toast.loading(t("uploading"));
try {
const res = await fetch(
`/api/v1/archives/${link.id}/preview`,
await updatePreview.mutateAsync(
{
method: "POST",
body: formData,
linkId: link.id as number,
file,
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("updated"));
setLink({ updatedAt: data.updatedAt, ...link });
}
},
}
);
if (!res.ok) {
throw new Error(await res.text());
}
const data = await res.json();
setLink({
...link,
preview: data.preview,
});
toast.success(t("uploaded"));
} catch (error) {
console.error(error);
} finally {
toast.dismiss(load);
}
}}
className="hidden"
/>
@ -277,11 +275,7 @@ export default function LinkDetails({
)}
</div>
{standalone ? (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<LinkIcon link={link} onClick={() => setIconPopover(true)} />
</div>
) : (
{!standalone && (permissions === true || permissions?.canUpdate) ? (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<div className="tooltip tooltip-bottom" data-tip={t("change_icon")}>
<LinkIcon
@ -316,6 +310,10 @@ export default function LinkDetails({
/>
)}
</div>
) : (
<div className="-mt-14 ml-8 relative w-fit pb-2">
<LinkIcon link={link} onClick={() => setIconPopover(true)} />
</div>
)}
<div className="max-w-xl sm:px-8 p-5 pb-8 pt-2">

View File

@ -49,7 +49,7 @@ export default function LinkActions({
await updateLink.mutateAsync(
{
...link,
pinnedBy: isAlreadyPinned ? undefined : [{ id: user.id }],
pinnedBy: isAlreadyPinned ? [{ id: undefined }] : [{ id: user.id }],
},
{
onSettled: (data, error) => {
@ -95,6 +95,9 @@ export default function LinkActions({
className={`absolute ${position || "top-3 right-3"} ${
alignToTop ? "" : "dropdown-end"
} z-20`}
tabIndex={0}
role="button"
onMouseDown={dropdownTriggerer}
onClick={() => setLinkModal(true)}
>
<div className="btn btn-ghost btn-sm btn-square text-neutral">
@ -120,7 +123,6 @@ export default function LinkActions({
alignToTop ? "" : "translate-y-10"
}`}
>
{(permissions === true || permissions?.canUpdate) && (
<li>
<div
role="button"
@ -136,7 +138,6 @@ export default function LinkActions({
: t("pin_to_dashboard")}
</div>
</li>
)}
<li>
<div
role="button"
@ -165,7 +166,7 @@ export default function LinkActions({
</div>
</li>
)}
{link.type === "url" && (
{link.type === "url" && permissions === true && (
<li>
<div
role="button"

View File

@ -154,7 +154,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
<div className="relative rounded-t-2xl h-40 overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""

View File

@ -91,5 +91,3 @@ const LinkPlaceholderIcon = ({
</div>
);
};
// `text-black aspect-square text-4xl ${iconClasses}`

View File

@ -146,7 +146,7 @@ export default function LinkMasonry({ link, flipDropdown, editMode }: Props) {
<div className="relative rounded-t-2xl overflow-hidden">
{previewAvailable(link) ? (
<Image
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true`}
src={`/api/v1/archives/${link.id}?format=${ArchivedFormat.jpeg}&preview=true&updatedAt=${link.updatedAt}`}
width={1280}
height={720}
alt=""

View File

@ -1,12 +1,7 @@
import React, { useEffect, useState } from "react";
import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import getPublicUserData from "@/lib/client/getPublicUserData";
import React, { useState } from "react";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
import { useDeleteLink, useGetLink, useUpdateLink } from "@/hooks/store/links";
import { useDeleteLink } from "@/hooks/store/links";
import Drawer from "../Drawer";
import LinkDetails from "../LinkDetails";
import Link from "next/link";
@ -34,86 +29,6 @@ export default function LinkModal({
activeMode,
}: Props) {
const { t } = useTranslation();
const getLink = useGetLink();
const { data: user = {} } = useUser();
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === user.id) {
setCollectionOwner({
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
link &&
(collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending"
: true) &&
(collectionOwner.archiveAsMonolith === true
? link.monolith && link.monolith !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable &&
link.readable !== "pending"
);
};
useEffect(() => {
(async () => {
await getLink.mutateAsync({
id: link.id as number,
});
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
await getLink.mutateAsync({
id: link.id as number,
});
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.monolith]);
const permissions = usePermissions(link.collection.id as number);
@ -136,6 +51,7 @@ export default function LinkModal({
onClick={() => onClose()}
></div>
{(permissions === true || permissions?.canUpdate) && (
<div className="flex gap-1 h-8 rounded-full bg-neutral-content bg-opacity-50 text-base-content p-1 text-xs duration-100 select-none z-10">
<div
className={clsx(
@ -160,6 +76,7 @@ export default function LinkModal({
Edit
</div>
</div>
)}
<div className="flex gap-2">
<div className={`dropdown dropdown-end z-20`}>
@ -174,7 +91,7 @@ export default function LinkModal({
<ul
className={`dropdown-content z-[20] menu shadow bg-base-200 border border-neutral-content rounded-box`}
>
{(permissions === true || permissions?.canUpdate) && (
{
<li>
<div
role="button"
@ -190,7 +107,7 @@ export default function LinkModal({
: t("pin_to_dashboard")}
</div>
</li>
)}
}
{link.type === "url" && permissions === true && (
<li>
<div

View File

@ -57,7 +57,7 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.Card)}
className={`btn btn-square btn-sm btn-ghost ${
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.Card
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
@ -67,7 +67,7 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
</button>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.Masonry)}
className={`btn btn-square btn-sm btn-ghost ${
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.Masonry
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"
@ -77,7 +77,7 @@ export default function ViewDropdown({ viewMode, setViewMode }: Props) {
</button>
<button
onClick={(e) => onChangeViewMode(e, ViewMode.List)}
className={`btn btn-square btn-sm btn-ghost ${
className={`btn w-[31%] btn-sm btn-ghost ${
viewMode === ViewMode.List
? "bg-primary/20 hover:bg-primary/20"
: "hover:bg-neutral/20"

View File

@ -13,6 +13,7 @@ import {
} from "@/types/global";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import Jimp from "jimp";
const useLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
@ -395,6 +396,41 @@ const useUploadFile = () => {
});
};
const useUpdatePreview = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ linkId, file }: { linkId: number; file: File }) => {
const formBody = new FormData();
if (!linkId || !file)
throw new Error("Error generating preview: Invalid parameters");
formBody.append("file", file);
const res = await fetch(
`/api/v1/archives/${linkId}?format=` + ArchivedFormat.jpeg,
{
body: formBody,
method: "POST",
}
);
const data = res.json();
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["links"] });
queryClient.invalidateQueries({ queryKey: ["dashboardData"] });
queryClient.invalidateQueries({ queryKey: ["collections"] });
queryClient.invalidateQueries({ queryKey: ["tags"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
const useBulkEditLinks = () => {
const queryClient = useQueryClient();
@ -479,4 +515,5 @@ export {
useGetLink,
useBulkEditLinks,
resetInfiniteQueryPagination,
useUpdatePreview,
};

View File

@ -25,15 +25,15 @@ export default async function updateLinkById(
(e: UsersAndCollections) => e.userId === userId
);
// If the user is able to create a link, they can pin it to their dashboard only.
if (canPinPermission) {
// If the user is part of a collection, they can pin it to their dashboard
if (canPinPermission && data.pinnedBy && data.pinnedBy[0]) {
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
data?.pinnedBy && data.pinnedBy[0].id === userId
? { connect: { id: userId } }
: { disconnect: { id: userId } },
},
@ -48,7 +48,7 @@ export default async function updateLinkById(
},
});
// return { response: updatedLink, status: 200 };
return { response: updatedLink, status: 200 };
}
const targetCollectionIsAccessible = await getPermission({

View File

@ -105,8 +105,6 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
response: "Collection is not accessible.",
});
// await uploadHandler(linkId, )
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER || 30000);
const numberOfLinksTheUserHas = await prisma.link.count({
@ -119,8 +117,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return res.status(400).json({
response:
"Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.",
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
});
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
@ -208,4 +205,94 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
});
});
}
// To update the link preview
else if (req.method === "PUT" && format === ArchivedFormat.jpeg) {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const user = await verifyUser({ req, res });
if (!user) return;
const collectionPermissions = await getPermission({
userId: user.id,
linkId,
});
if (!collectionPermissions)
return res.status(400).json({
response: "Collection is not accessible.",
});
const memberHasAccess = collectionPermissions.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
);
if (!(collectionPermissions.ownerId === user.id || memberHasAccess))
return res.status(400).json({
response: "Collection is not accessible.",
});
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
);
const form = formidable({
maxFields: 1,
maxFiles: 1,
maxFileSize: NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024,
});
form.parse(req, async (err, fields, files) => {
const allowedMIMETypes = ["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(400).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
});
} else {
const fileBuffer = fs.readFileSync(files.file[0].filepath);
if (
Buffer.byteLength(fileBuffer) >
1024 * 1024 * Number(NEXT_PUBLIC_MAX_FILE_BUFFER)
)
return res.status(400).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
});
const linkStillExists = await prisma.link.update({
where: { id: linkId },
data: {
updatedAt: new Date(),
},
});
if (linkStillExists) {
const collectionId = collectionPermissions.id;
createFolder({
filePath: `archives/preview/${collectionId}`,
});
generatePreview(fileBuffer, collectionId, linkId);
}
fs.unlinkSync(files.file[0].filepath);
if (linkStillExists)
return res.status(200).json({
response: linkStillExists,
});
else return res.status(400).json({ response: "Link not found." });
}
});
}
}

View File

@ -297,7 +297,7 @@
"for_collection": "For {{name}}",
"create_new_collection": "Create a New Collection",
"color": "Color",
"reset": "Reset",
"reset_defaults": "Reset to Defaults",
"updating_collection": "Updating Collection...",
"collection_name_placeholder": "e.g. Example Collection",
"collection_description_placeholder": "The purpose of this Collection...",