feat: added banner for orgs (#13644)
Use this banner to test https://pbs.twimg.com/profile_banners/1063089191565905920/1685635865/1500x500 - [x] add backend - [x] add file upload (require 1500x500px) in Organization/Appearance - [x] for now, apply this to all Org event types. later we could think about sub-team overwrites, but for now org is enough 1) Upload Banner Screen <img width="1512" alt="Screenshot 2024-02-15 at 10 26 36 PM" src="https://github.com/calcom/cal.com/assets/53316345/3c7417ee-7ef7-4138-b5f8-38f255a407fa"> 2) <img width="1512" alt="Screenshot 2024-02-15 at 10 27 02 PM" src="https://github.com/calcom/cal.com/assets/53316345/a74dffbf-3727-4b2f-82d5-fefd55c12901"> 3) Go to Org Team Event Types <img width="1512" alt="Screenshot 2024-02-15 at 10 27 21 PM" src="https://github.com/calcom/cal.com/assets/53316345/5f188bd7-8a66-4da7-be73-1b5e178b5b3b"> __________________________________________________ DESIGNS  __   mobile: 
This commit is contained in:
@@ -96,6 +96,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
isInstantMeeting: eventData.isInstantEvent && queryIsInstantMeeting ? true : false,
|
||||
themeBasis: null,
|
||||
orgBannerUrl: eventData?.team?.parent?.bannerUrl ?? "",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ export const getMultipleDurationValue = (
|
||||
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
export default function Type({
|
||||
slug,
|
||||
user,
|
||||
@@ -34,6 +35,7 @@ export default function Type({
|
||||
isBrandingHidden,
|
||||
eventData,
|
||||
isInstantMeeting,
|
||||
orgBannerUrl,
|
||||
}: PageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -66,6 +68,7 @@ export default function Type({
|
||||
searchParams?.get("duration"),
|
||||
eventData.length
|
||||
)}
|
||||
orgBannerUrl={orgBannerUrl}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -251,6 +251,7 @@
|
||||
"confirm_auth_change": "This will change the way you log in",
|
||||
"confirm_auth_email_change": "Changing the email address will disconnect your current authentication method to log in to Cal.com. We will ask you to verify your new email address. Moving forward, you will be logged out and use your new email address to log in instead of your current authentication method after setting your password by following the instructions that will be sent to your mail.",
|
||||
"reset_your_password": "Set your new password with the instructions sent to your email address.",
|
||||
"org_banner_instructions":"Please upload image of {{width}} width and {{height}} height.",
|
||||
"email_change": "Log back in with your new email address and password.",
|
||||
"create_your_account": "Create your account",
|
||||
"create_your_calcom_account": "Create your Cal.com account",
|
||||
@@ -2083,6 +2084,7 @@
|
||||
"organizations": "Organizations",
|
||||
"upload_cal_video_logo":"Upload Cal Video Logo",
|
||||
"update_cal_video_logo":"Update Cal Video Logo",
|
||||
"upload_banner": "Upload Banner",
|
||||
"cal_video_logo_upload_instruction":"To ensure your logo is visible against Cal video's dark background, please upload a light-colored image in PNG or SVG format to maintain transparency.",
|
||||
"org_admin_other_teams": "Other teams",
|
||||
"org_admin_other_teams_description": "Here you can see teams inside your organization that you are not part of. You can add yourself to them if needed.",
|
||||
|
||||
@@ -66,6 +66,7 @@ const BookerComponent = ({
|
||||
bookerLayout,
|
||||
schedule,
|
||||
verifyCode,
|
||||
orgBannerUrl,
|
||||
}: BookerProps & WrappedBookerProps) => {
|
||||
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
|
||||
const selectedDate = useBookerStore((state) => state.selectedDate);
|
||||
@@ -338,6 +339,15 @@ const BookerComponent = ({
|
||||
<BookerSection
|
||||
area="meta"
|
||||
className="max-w-screen flex w-full flex-col md:w-[var(--booker-meta-width)]">
|
||||
{orgBannerUrl && (
|
||||
<img
|
||||
loading="eager"
|
||||
className="-mb-9 h-40 max-h-40 rounded-tl-md sm:max-h-24"
|
||||
alt="org banner"
|
||||
src={orgBannerUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EventMeta event={event.data} isPending={event.isPending} />
|
||||
{layout !== BookerLayouts.MONTH_VIEW &&
|
||||
!(layout === "mobile" && bookerState === "booking") && (
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { GetBookingType } from "../lib/get-booking";
|
||||
export interface BookerProps {
|
||||
eventSlug: string;
|
||||
username: string;
|
||||
orgBannerUrl?: string | null;
|
||||
|
||||
/**
|
||||
* Whether is a team or org, we gather basic info from both
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Button,
|
||||
Form,
|
||||
ImageUploader,
|
||||
BannerUploader,
|
||||
Label,
|
||||
Meta,
|
||||
showToast,
|
||||
@@ -40,12 +41,14 @@ import { useOrgBranding } from "../../../organizations/context/provider";
|
||||
const orgProfileFormSchema = z.object({
|
||||
name: z.string(),
|
||||
logo: z.string().nullable(),
|
||||
banner: z.string().nullable(),
|
||||
bio: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
logo: string | null;
|
||||
banner: string | null;
|
||||
bio: string;
|
||||
slug: string;
|
||||
};
|
||||
@@ -110,6 +113,7 @@ const OrgProfileView = () => {
|
||||
const defaultValues: FormValues = {
|
||||
name: currentOrganisation?.name || "",
|
||||
logo: currentOrganisation?.logo || "",
|
||||
banner: currentOrganisation?.bannerUrl || "",
|
||||
bio: currentOrganisation?.bio || "",
|
||||
slug:
|
||||
currentOrganisation?.slug ||
|
||||
@@ -178,6 +182,7 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
|
||||
name: (res.data?.name || "") as string,
|
||||
bio: (res.data?.bio || "") as string,
|
||||
slug: defaultValues["slug"],
|
||||
banner: (res.data?.bannerUrl || "") as string,
|
||||
});
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
@@ -201,6 +206,7 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
bio: values.bio,
|
||||
banner: values.banner,
|
||||
};
|
||||
|
||||
mutation.mutate(variables);
|
||||
@@ -250,6 +256,53 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="banner"
|
||||
render={({ field: { value } }) => {
|
||||
const showRemoveBannerButton = !!value;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Avatar
|
||||
data-testid="profile-upload-banner"
|
||||
alt={`${defaultValues.name} Banner` || ""}
|
||||
imageSrc={value}
|
||||
size="lg"
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<div className="flex gap-2">
|
||||
<BannerUploader
|
||||
height={500}
|
||||
width={1500}
|
||||
target="banner"
|
||||
uploadInstruction={t("org_banner_instructions", { height: 500, width: 1500 })}
|
||||
id="banner-upload"
|
||||
buttonMsg={t("upload_banner")}
|
||||
handleAvatarChange={(newBanner) => {
|
||||
form.setValue("banner", newBanner, { shouldDirty: true });
|
||||
}}
|
||||
imageSrc={value || undefined}
|
||||
triggerButtonColor={showRemoveBannerButton ? "secondary" : "primary"}
|
||||
/>
|
||||
{showRemoveBannerButton && (
|
||||
<Button
|
||||
color="destructive"
|
||||
onClick={() => {
|
||||
form.setValue("banner", "", { shouldDirty: true });
|
||||
}}>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
||||
@@ -81,6 +81,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "bannerUrl" TEXT;
|
||||
@@ -348,6 +348,7 @@ model Team {
|
||||
brandColor String?
|
||||
darkBrandColor String?
|
||||
verifiedNumbers VerifiedNumber[]
|
||||
bannerUrl String?
|
||||
parentId Int?
|
||||
parent Team? @relation("organization", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
children Team[] @relation("organization")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers";
|
||||
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
import { uploadLogo } from "@calcom/lib/server/uploadLogo";
|
||||
import { closeComUpdateTeam } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
@@ -21,6 +23,30 @@ type UpdateOptions = {
|
||||
input: TUpdateInputSchema;
|
||||
};
|
||||
|
||||
const uploadBanner = async ({ teamId, banner: data }: { teamId: number; banner: string }) => {
|
||||
const objectKey = uuidv4();
|
||||
|
||||
await prisma.avatar.upsert({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId: 0,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId: teamId,
|
||||
data,
|
||||
objectKey,
|
||||
},
|
||||
update: {
|
||||
data,
|
||||
objectKey,
|
||||
},
|
||||
});
|
||||
|
||||
return `/api/avatar/${objectKey}.png`;
|
||||
};
|
||||
|
||||
export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
// A user can only have one org so we pass in their currentOrgId here
|
||||
const currentOrgId = ctx.user?.organization?.id || input.orgId;
|
||||
@@ -53,10 +79,23 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
metadata: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
bannerUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!prevOrganisation) throw new TRPCError({ code: "NOT_FOUND", message: "Organisation not found." });
|
||||
|
||||
let bannerUrl = prevOrganisation.bannerUrl;
|
||||
if (input.banner && input.banner.startsWith("data:image/png;base64,")) {
|
||||
const banner = await resizeBase64Image(input.banner, { maxSize: 1500 });
|
||||
bannerUrl = await uploadBanner({
|
||||
banner: banner,
|
||||
teamId: currentOrgId,
|
||||
});
|
||||
} else if (input.banner === "") {
|
||||
bannerUrl = null;
|
||||
}
|
||||
|
||||
const { mergeMetadata } = getMetadataHelpers(teamMetadataSchema.unwrap(), prevOrganisation.metadata);
|
||||
|
||||
const data: Prisma.TeamUpdateArgs["data"] = {
|
||||
@@ -72,6 +111,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
weekStart: input.weekStart,
|
||||
timeFormat: input.timeFormat,
|
||||
metadata: mergeMetadata({ ...input.metadata }),
|
||||
bannerUrl,
|
||||
};
|
||||
|
||||
if (input.logo && input.logo.startsWith("data:image/png;base64,")) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ZUpdateInputSchema = z.object({
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => v || null),
|
||||
banner: z.string().nullable().optional(),
|
||||
slug: z.string().optional(),
|
||||
hideBranding: z.boolean().optional(),
|
||||
hideBookATeamMember: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useState, useEffect } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import type { ButtonColor } from "../..";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger, DialogFooter } from "../..";
|
||||
import { showToast } from "../toast";
|
||||
import { useFileReader, createImage, Slider } from "./Common";
|
||||
import type { FileEvent, Area } from "./Common";
|
||||
|
||||
type BannerUploaderProps = {
|
||||
id: string;
|
||||
buttonMsg: string;
|
||||
handleAvatarChange: (imageSrc: string) => void;
|
||||
imageSrc?: string;
|
||||
target: string;
|
||||
triggerButtonColor?: ButtonColor;
|
||||
uploadInstruction?: string;
|
||||
disabled?: boolean;
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
function CropContainer({
|
||||
onCropComplete,
|
||||
imageSrc,
|
||||
}: {
|
||||
imageSrc: string;
|
||||
onCropComplete: (croppedAreaPixels: Area) => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
const handleZoomSliderChange = (value: number) => {
|
||||
value < 1 ? setZoom(1) : setZoom(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="relative h-52 w-[40rem]">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={3}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={(croppedArea, croppedAreaPixels) => onCropComplete(croppedAreaPixels)}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
label={t("slide_zoom_drag_instructions")}
|
||||
changeHandler={handleZoomSliderChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BannerUploader({
|
||||
target,
|
||||
id,
|
||||
buttonMsg,
|
||||
handleAvatarChange,
|
||||
triggerButtonColor,
|
||||
imageSrc,
|
||||
uploadInstruction,
|
||||
disabled = false,
|
||||
height,
|
||||
width,
|
||||
}: BannerUploaderProps) {
|
||||
const { t } = useLocale();
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const [{ result }, setFile] = useFileReader({
|
||||
method: "readAsDataURL",
|
||||
});
|
||||
|
||||
const onInputFile = async (e: FileEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = 5 * 1000000; // max limit 5mb
|
||||
const file = e.target.files[0];
|
||||
|
||||
if (file.size > limit) {
|
||||
showToast(t("image_size_limit_exceed"), "error");
|
||||
} else {
|
||||
setFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const showCroppedImage = useCallback(
|
||||
async (croppedAreaPixels: Area | null) => {
|
||||
try {
|
||||
if (!croppedAreaPixels) return;
|
||||
const croppedImage = await getCroppedImg(
|
||||
result as string /* result is always string when using readAsDataUrl */,
|
||||
croppedAreaPixels,
|
||||
height,
|
||||
width
|
||||
);
|
||||
handleAvatarChange(croppedImage);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[result, height, width, handleAvatarChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDimensions = async () => {
|
||||
const image = await createImage(
|
||||
result as string /* result is always string when using readAsDataUrl */
|
||||
);
|
||||
if (image.naturalWidth !== width || image.naturalHeight !== height) {
|
||||
showToast(t("org_banner_instructions", { height, width }), "warning");
|
||||
}
|
||||
};
|
||||
if (result) {
|
||||
checkDimensions();
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(opened) => {
|
||||
// unset file on close
|
||||
if (!opened) {
|
||||
setFile(null);
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
color={triggerButtonColor ?? "secondary"}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-testid="open-upload-avatar-dialog"
|
||||
className="cursor-pointer py-1 text-sm">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:w-[45rem] sm:max-w-[45rem]" title={t("upload_target", { target })}>
|
||||
<div className="mb-4">
|
||||
<div className="cropper mt-6 flex flex-col items-center justify-center p-8">
|
||||
{!result && (
|
||||
<div className="bg-muted flex h-60 w-full items-center justify-start">
|
||||
{!imageSrc || checkIfItFallbackImage(imageSrc) ? (
|
||||
<p className="text-emphasis w-full text-center text-sm sm:text-xs">
|
||||
{t("no_target", { target })}
|
||||
</p>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img className="h-full w-full" src={imageSrc} alt={target} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||
<label
|
||||
data-testid="open-upload-image-filechooser"
|
||||
className="bg-subtle hover:bg-muted hover:text-emphasis border-subtle text-default mt-8 cursor-pointer rounded-sm border px-3 py-1 text-xs font-medium leading-4 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
<input
|
||||
onInput={onInputFile}
|
||||
type="file"
|
||||
name={id}
|
||||
placeholder={t("upload_image")}
|
||||
className="text-default pointer-events-none absolute mt-4 opacity-0 "
|
||||
accept="image/*"
|
||||
/>
|
||||
{t("choose_a_file")}
|
||||
</label>
|
||||
{uploadInstruction && (
|
||||
<p className="text-muted mt-4 text-center text-sm">({uploadInstruction})</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="relative">
|
||||
<DialogClose color="minimal">{t("cancel")}</DialogClose>
|
||||
<DialogClose
|
||||
data-testid="upload-avatar"
|
||||
color="primary"
|
||||
onClick={() => showCroppedImage(croppedAreaPixels)}>
|
||||
{t("save")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
async function getCroppedImg(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
height: number,
|
||||
width: number
|
||||
): Promise<string> {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Context is null, this should never happen.");
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
type ReadAsMethod = "readAsText" | "readAsDataURL" | "readAsArrayBuffer" | "readAsBinaryString";
|
||||
|
||||
type UseFileReaderProps = {
|
||||
method: ReadAsMethod;
|
||||
onLoad?: (result: unknown) => void;
|
||||
};
|
||||
|
||||
export const useFileReader = (options: UseFileReaderProps) => {
|
||||
const { method = "readAsText", onLoad } = options;
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<DOMException | null>(null);
|
||||
const [result, setResult] = useState<string | ArrayBuffer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file && result) {
|
||||
setResult(null);
|
||||
}
|
||||
}, [file, result]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadstart = () => setLoading(true);
|
||||
reader.onloadend = () => setLoading(false);
|
||||
reader.onerror = () => setError(reader.error);
|
||||
|
||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||
setResult(e.target?.result ?? null);
|
||||
if (onLoad) {
|
||||
onLoad(e.target?.result ?? null);
|
||||
}
|
||||
};
|
||||
reader[method](file);
|
||||
}, [file, method, onLoad]);
|
||||
|
||||
return [{ result, error, file, loading }, setFile] as const;
|
||||
};
|
||||
|
||||
export const createImage = (url: string) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener("load", () => resolve(image));
|
||||
image.addEventListener("error", (error) => reject(error));
|
||||
image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
export const Slider = ({
|
||||
value,
|
||||
label,
|
||||
changeHandler,
|
||||
...props
|
||||
}: Omit<SliderPrimitive.SliderProps, "value"> & {
|
||||
value: number;
|
||||
label: string;
|
||||
changeHandler: (value: number) => void;
|
||||
}) => (
|
||||
<SliderPrimitive.Root
|
||||
className="slider mt-2"
|
||||
value={[value]}
|
||||
aria-label={label}
|
||||
onValueChange={(value: number[]) => changeHandler(value[0] ?? value)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="slider-track">
|
||||
<SliderPrimitive.Range className="slider-range" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="slider-thumb" />
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
|
||||
export interface FileEvent<T = Element> extends FormEvent<T> {
|
||||
target: EventTarget & T;
|
||||
}
|
||||
|
||||
export type Area = {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
import type { FormEvent } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
|
||||
import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage";
|
||||
@@ -9,58 +7,11 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ButtonColor } from "../..";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogTrigger, DialogFooter } from "../..";
|
||||
import { showToast } from "../toast";
|
||||
|
||||
type ReadAsMethod = "readAsText" | "readAsDataURL" | "readAsArrayBuffer" | "readAsBinaryString";
|
||||
|
||||
type UseFileReaderProps = {
|
||||
method: ReadAsMethod;
|
||||
onLoad?: (result: unknown) => void;
|
||||
};
|
||||
|
||||
type Area = {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
import { useFileReader, createImage, Slider } from "./Common";
|
||||
import type { FileEvent, Area } from "./Common";
|
||||
|
||||
const MAX_IMAGE_SIZE = 512;
|
||||
|
||||
const useFileReader = (options: UseFileReaderProps) => {
|
||||
const { method = "readAsText", onLoad } = options;
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<DOMException | null>(null);
|
||||
const [result, setResult] = useState<string | ArrayBuffer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file && result) {
|
||||
setResult(null);
|
||||
}
|
||||
}, [file, result]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadstart = () => setLoading(true);
|
||||
reader.onloadend = () => setLoading(false);
|
||||
reader.onerror = () => setError(reader.error);
|
||||
|
||||
reader.onload = (e: ProgressEvent<FileReader>) => {
|
||||
setResult(e.target?.result ?? null);
|
||||
if (onLoad) {
|
||||
onLoad(e.target?.result ?? null);
|
||||
}
|
||||
};
|
||||
reader[method](file);
|
||||
}, [file, method, onLoad]);
|
||||
|
||||
return [{ result, error, file, loading }, setFile] as const;
|
||||
};
|
||||
|
||||
type ImageUploaderProps = {
|
||||
id: string;
|
||||
buttonMsg: string;
|
||||
@@ -72,10 +23,6 @@ type ImageUploaderProps = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
interface FileEvent<T = Element> extends FormEvent<T> {
|
||||
target: EventTarget & T;
|
||||
}
|
||||
|
||||
// This is separate to prevent loading the component until file upload
|
||||
function CropContainer({
|
||||
onCropComplete,
|
||||
@@ -231,15 +178,6 @@ export default function ImageUploader({
|
||||
);
|
||||
}
|
||||
|
||||
const createImage = (url: string) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.addEventListener("load", () => resolve(image));
|
||||
image.addEventListener("error", (error) => reject(error));
|
||||
image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string> {
|
||||
const image = await createImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
@@ -248,6 +186,7 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string>
|
||||
|
||||
const maxSize = Math.max(image.naturalWidth, image.naturalHeight);
|
||||
const resizeRatio = MAX_IMAGE_SIZE / maxSize < 1 ? Math.max(MAX_IMAGE_SIZE / maxSize, 0.75) : 1;
|
||||
|
||||
// huh, what? - Having this turned off actually improves image quality as otherwise anti-aliasing is applied
|
||||
// this reduces the quality of the image overall because it anti-aliases the existing, copied image; blur results
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
@@ -279,26 +218,3 @@ async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise<string>
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
const Slider = ({
|
||||
value,
|
||||
label,
|
||||
changeHandler,
|
||||
...props
|
||||
}: Omit<SliderPrimitive.SliderProps, "value"> & {
|
||||
value: number;
|
||||
label: string;
|
||||
changeHandler: (value: number) => void;
|
||||
}) => (
|
||||
<SliderPrimitive.Root
|
||||
className="slider mt-2"
|
||||
value={[value]}
|
||||
aria-label={label}
|
||||
onValueChange={(value: number[]) => changeHandler(value[0] ?? value)}
|
||||
{...props}>
|
||||
<SliderPrimitive.Track className="slider-track">
|
||||
<SliderPrimitive.Range className="slider-range" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="slider-thumb" />
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as ImageUploader } from "./ImageUploader";
|
||||
export { default as BannerUploader } from "./BannerUploader";
|
||||
|
||||
@@ -151,6 +151,8 @@ export {
|
||||
export { default as MultiSelectCheckboxes } from "./components/form/checkbox/MultiSelectCheckboxes";
|
||||
export type { Option as MultiSelectCheckboxesOptionType } from "./components/form/checkbox/MultiSelectCheckboxes";
|
||||
export { default as ImageUploader } from "./components/image-uploader/ImageUploader";
|
||||
export { default as BannerUploader } from "./components/image-uploader/BannerUploader";
|
||||
|
||||
export type { ButtonColor } from "./components/button/Button";
|
||||
export { CreateButton, CreateButtonWithTeamsList } from "./components/createButton";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user