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


![CleanShot 2024-02-12 at 15 02 37@2x](https://github.com/calcom/cal.com/assets/8019099/85602120-1ced-4627-ae7c-375d33ccf1e9)


__


![CleanShot 2024-02-12 at 14 55 23@2x](https://github.com/calcom/cal.com/assets/8019099/e8a9e9c3-4e01-4a07-a2e0-7259027feb36)


![CleanShot 2024-02-12 at 14 55 34@2x](https://github.com/calcom/cal.com/assets/8019099/56500809-6c6e-49a6-9229-43190dd711b7)

mobile:
![CleanShot 2024-02-12 at 14 56 00@2x](https://github.com/calcom/cal.com/assets/8019099/f90b58b2-3104-4c00-bc18-a2a54ac8e7e7)
This commit is contained in:
Peer Richelsen
2024-03-06 16:18:56 -07:00
committed by GitHub
parent 0972307998
commit dfd683ffc4
16 changed files with 435 additions and 88 deletions
@@ -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 ?? "",
},
};
};
+3
View File
@@ -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;
+1
View File
@@ -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";
+2
View File
@@ -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";