Skip to content

Commit

Permalink
feat(mfts#599): Add custom favicon icon in document link (mfts#608)
Browse files Browse the repository at this point in the history
* add custome favicon in link

* update migration file

* feat: limit accepted file types

* fix: upload meta favicon separately from meta image

* fix: head tag overwrite for favicon

---------

Co-authored-by: Marc Seitz <[email protected]>
  • Loading branch information
AshishViradiya153 and mfts authored Oct 4, 2024
1 parent 29ef5bc commit b178d32
Show file tree
Hide file tree
Showing 20 changed files with 593 additions and 83 deletions.
Binary file removed app/favicon.ico
Binary file not shown.
17 changes: 17 additions & 0 deletions components/links/link-sheet/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const DEFAULT_LINK_PROPS = (linkType: LinkType) => ({
metaTitle: null,
metaDescription: null,
metaImage: null,
metaFavicon: null,
enabledQuestion: false,
questionText: null,
questionType: null,
Expand Down Expand Up @@ -91,6 +92,7 @@ export type DEFAULT_LINK_TYPE = {
metaTitle: string | null; // metatags
metaDescription: string | null; // metatags
metaImage: string | null; // metatags
metaFavicon: string | null; // metaFavicon
enableQuestion?: boolean; // feedback question
questionText: string | null;
questionType: string | null;
Expand Down Expand Up @@ -155,6 +157,20 @@ export default function LinkSheet({
setData({ ...data, metaImage: blobUrl });
}

// Upload meta favicon if it's a data URL
let blobUrlFavicon: string | null =
data.metaFavicon && data.metaFavicon.startsWith("data:")
? null
: data.metaFavicon;
if (data.metaFavicon && data.metaFavicon.startsWith("data:")) {
const blobFavicon = convertDataUrlToFile({ dataUrl: data.metaFavicon });
blobUrlFavicon = await uploadImage(blobFavicon);
setData({
...data,
metaFavicon: blobUrlFavicon,
});
}

let endpoint = "/api/links";
let method = "POST";

Expand All @@ -172,6 +188,7 @@ export default function LinkSheet({
body: JSON.stringify({
...data,
metaImage: blobUrl,
metaFavicon: blobUrlFavicon,
targetId: targetId,
linkType: linkType,
teamId: teamInfo?.currentTeam?.id,
Expand Down
197 changes: 189 additions & 8 deletions components/links/link-sheet/og-section.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { useCallback, useEffect, useState } from "react";
import { ChangeEvent, useCallback, useEffect, useState } from "react";

import { useTeam } from "@/context/team-context";
import { LinkPreset } from "@prisma/client";
import { Label } from "@radix-ui/react-label";
import { motion } from "framer-motion";
import { Upload as ArrowUpTrayIcon } from "lucide-react";
import { Upload as ArrowUpTrayIcon, PlusIcon } from "lucide-react";
import useSWRImmutable from "swr/immutable";

import { Input } from "@/components/ui/input";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { Textarea } from "@/components/ui/textarea";

import { FADE_IN_ANIMATION_SETTINGS } from "@/lib/constants";
import { cn, fetcher } from "@/lib/utils";
import { cn, fetcher, validateImageDimensions } from "@/lib/utils";
import { resizeImage } from "@/lib/utils/resize-image";

import { DEFAULT_LINK_TYPE } from ".";
Expand All @@ -35,7 +36,13 @@ export default function OGSection({
}: LinkUpgradeOptions) => void;
editLink: boolean;
}) {
const { enableCustomMetatag, metaTitle, metaDescription, metaImage } = data;
const {
enableCustomMetatag,
metaTitle,
metaDescription,
metaImage,
metaFavicon,
} = data;
const teamInfo = useTeam();
const { data: presets } = useSWRImmutable<LinkPreset>(
`/api/teams/${teamInfo?.currentTeam?.id}/presets`,
Expand All @@ -45,6 +52,8 @@ export default function OGSection({
const [enabled, setEnabled] = useState<boolean>(false);
const [fileError, setFileError] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);
const [faviconFileError, setFaviconFileError] = useState<string | null>(null);
const [faviconDragActive, setFaviconDragActive] = useState(false);

const onChangePicture = useCallback(
async (e: any) => {
Expand Down Expand Up @@ -72,11 +81,15 @@ export default function OGSection({
}, [enableCustomMetatag]);

useEffect(() => {
if (presets && !(metaTitle || metaDescription || metaImage)) {
if (
presets &&
!(metaTitle || metaDescription || metaImage || metaFavicon)
) {
const preset = presets;
if (preset) {
setData((prev) => ({
...prev,
metaFavicon: prev.metaFavicon || preset.metaFavicon,
metaImage: prev.metaImage || preset.metaImage,
metaTitle: prev.metaTitle || preset.metaTitle,
metaDescription: prev.metaDescription || preset.metaDescription,
Expand All @@ -88,6 +101,7 @@ export default function OGSection({
presets,
setData,
editLink,
metaFavicon,
enableCustomMetatag,
metaTitle,
metaDescription,
Expand All @@ -110,6 +124,48 @@ export default function OGSection({
});
};

const onChangeFavicon = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
setFaviconFileError(null);
const file = e.target.files && e.target.files[0];
if (file) {
if (file.size / 1024 / 1024 > 1) {
setFaviconFileError("File size too big (max 1MB)");
} else if (
file.type !== "image/png" &&
file.type !== "image/x-icon" &&
file.type !== "image/svg+xml"
) {
setFaviconFileError(
"File type not supported (.png, .ico, .svg only)",
);
} else {
const image = await resizeImage(file, {
width: 36,
height: 36,
quality: 1,
});
const isValidDimensions = await validateImageDimensions(
image,
16,
48,
);
if (!isValidDimensions) {
setFaviconFileError(
"Image dimensions must be between 16x16 and 48x48",
);
} else {
setData((prev) => ({
...prev,
metaFavicon: image,
}));
}
}
}
},
[setData],
);

return (
<div className="pb-5">
<LinkItem
Expand Down Expand Up @@ -177,7 +233,8 @@ export default function OGSection({
setFileError("File size too big (max 5MB)");
} else if (
file.type !== "image/png" &&
file.type !== "image/jpeg"
file.type !== "image/jpeg" &&
file.type !== "image/jpg"
) {
setFileError(
"File type not supported (.png or .jpg only)",
Expand Down Expand Up @@ -229,13 +286,137 @@ export default function OGSection({
id="image"
name="image"
type="file"
accept="image/*"
accept="image/png,image/jpeg,image/jpg"
className="sr-only"
onChange={onChangePicture}
/>
</div>
</div>

<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="faviconIcon">
<p className="block text-sm font-medium text-foreground">
Favicon Icon{" "}
<span className="text-sm italic text-muted-foreground">
(max 1 MB)
</span>
</p>
</Label>
{faviconFileError ? (
<p className="text-sm text-red-500">{faviconFileError}</p>
) : null}
</div>
<label
htmlFor="faviconIcon"
className="group relative mt-1 flex h-[4rem] w-[12rem] cursor-pointer flex-col items-center justify-center rounded-md border border-gray-300 bg-white shadow-sm transition-all hover:bg-gray-50"
style={{
backgroundImage:
"linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(135deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(135deg, transparent 75%, #ccc 75%)",
backgroundSize: "20px 20px",
backgroundPosition: "0 0, 10px 0, 10px -10px, 0px 10px",
}}
>
{false && (
<div className="absolute z-[5] flex h-full w-full items-center justify-center rounded-md bg-white">
<LoadingSpinner />
</div>
)}
<div
className="absolute z-[5] h-full w-full rounded-md"
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setFaviconDragActive(true);
}}
onDragEnter={(e) => {
e.preventDefault();
e.stopPropagation();
setFaviconDragActive(true);
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setFaviconDragActive(false);
}}
onDrop={async (e) => {
e.preventDefault();
e.stopPropagation();
setFaviconDragActive(false);
setFaviconFileError(null);
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (file) {
if (file.size / 1024 / 1024 > 1) {
setFaviconFileError("File size too big (max 1MB)");
} else if (
file.type !== "image/png" &&
file.type !== "image/x-icon" &&
file.type !== "image/svg+xml"
) {
setFaviconFileError(
"File type not supported (.png, .ico, .svg only)",
);
} else {
const image = await resizeImage(file, {
width: 36,
height: 36,
quality: 1,
});
const isValidDimensions = await validateImageDimensions(
image,
16,
48,
);
if (!isValidDimensions) {
setFaviconFileError(
"Image dimensions must be between 16x16 and 48x48",
);
} else {
setData((prev) => ({
...prev,
metaFavicon: image,
}));
}
}
}
}}
/>
<div
className={`${
faviconDragActive
? "cursor-copy border-2 border-black bg-gray-50 opacity-100"
: ""
} absolute z-[3] flex h-full w-full flex-col items-center justify-center rounded-md bg-white transition-all ${
metaFavicon
? "opacity-0 group-hover:opacity-100"
: "group-hover:bg-gray-50"
}`}
>
<PlusIcon
className={`${
faviconDragActive ? "scale-110" : "scale-100"
} h-7 w-7 text-gray-500 transition-all duration-75 group-hover:scale-110 group-active:scale-95`}
/>
<span className="sr-only">OG image upload</span>
</div>
{metaFavicon && (
<img
src={metaFavicon}
alt="Preview"
className="h-full w-full rounded-md object-contain"
/>
)}
</label>
<div className="mt-1 hidden rounded-md shadow-sm">
<input
id="faviconIcon"
name="favicon"
type="file"
accept="image/png,image/x-icon,image/svg+xml"
className="sr-only"
onChange={onChangeFavicon}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<p className="block text-sm font-medium text-foreground">Title</p>
Expand Down
1 change: 1 addition & 0 deletions components/links/links-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export default function LinksTable({
metaTitle: link.metaTitle,
metaDescription: link.metaDescription,
metaImage: link.metaImage,
metaFavicon: link.metaFavicon,
enableAgreement: link.enableAgreement ? link.enableAgreement : false,
agreementId: link.agreementId,
showBanner: link.showBanner ?? false,
Expand Down
Loading

0 comments on commit b178d32

Please sign in to comment.