first commit
This commit is contained in:
225
calcom/packages/ui/components/image-uploader/BannerUploader.tsx
Normal file
225
calcom/packages/ui/components/image-uploader/BannerUploader.tsx
Normal file
@@ -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-${target}-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");
|
||||
}
|
||||
88
calcom/packages/ui/components/image-uploader/Common.tsx
Normal file
88
calcom/packages/ui/components/image-uploader/Common.tsx
Normal file
@@ -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;
|
||||
};
|
||||
222
calcom/packages/ui/components/image-uploader/ImageUploader.tsx
Normal file
222
calcom/packages/ui/components/image-uploader/ImageUploader.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useState } 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";
|
||||
|
||||
const MAX_IMAGE_SIZE = 512;
|
||||
|
||||
type ImageUploaderProps = {
|
||||
id: string;
|
||||
buttonMsg: string;
|
||||
handleAvatarChange: (imageSrc: string) => void;
|
||||
imageSrc?: string;
|
||||
target: string;
|
||||
triggerButtonColor?: ButtonColor;
|
||||
uploadInstruction?: string;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
// This is separate to prevent loading the component until file upload
|
||||
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="crop-container h-40 max-h-40 w-40 rounded-full">
|
||||
<div className="relative h-40 w-40 rounded-full">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
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 ImageUploader({
|
||||
target,
|
||||
id,
|
||||
buttonMsg,
|
||||
handleAvatarChange,
|
||||
triggerButtonColor,
|
||||
imageSrc,
|
||||
uploadInstruction,
|
||||
disabled = false,
|
||||
testId,
|
||||
}: ImageUploaderProps) {
|
||||
const { t } = useLocale();
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const [{ result }, setFile] = useFileReader({
|
||||
method: "readAsDataURL",
|
||||
});
|
||||
|
||||
const onInputFile = (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
|
||||
);
|
||||
handleAvatarChange(croppedImage);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
[result, handleAvatarChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(opened) => {
|
||||
// unset file on close
|
||||
if (!opened) {
|
||||
setFile(null);
|
||||
}
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
color={triggerButtonColor ?? "secondary"}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-testid={testId ? `open-upload-${testId}-dialog` : "open-upload-avatar-dialog"}
|
||||
className="cursor-pointer py-1 text-sm">
|
||||
{buttonMsg}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent 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-20 max-h-20 w-20 items-center justify-start rounded-full">
|
||||
{!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-20 w-20 rounded-full" src={imageSrc} alt={target} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{result && <CropContainer imageSrc={result as string} onCropComplete={setCroppedAreaPixels} />}
|
||||
<label
|
||||
data-testid={testId ? `open-upload-${testId}-filechooser` : "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={testId ? `upload-${testId}` : "upload-avatar"}
|
||||
color="primary"
|
||||
onClick={() => showCroppedImage(croppedAreaPixels)}>
|
||||
{t("save")}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
async function getCroppedImg(imageSrc: string, pixelCrop: Area): 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.");
|
||||
|
||||
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;
|
||||
// pixelCrop is always 1:1 - width = height
|
||||
canvas.width = canvas.height = Math.min(maxSize * resizeRatio, pixelCrop.width);
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height
|
||||
);
|
||||
|
||||
// on very low ratios, the quality of the resize becomes awful. For this reason the resizeRatio is limited to 0.75
|
||||
if (resizeRatio <= 0.75) {
|
||||
// With a smaller image, thus improved ratio. Keep doing this until the resizeRatio > 0.75.
|
||||
return getCroppedImg(canvas.toDataURL("image/png"), {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Canvas, Meta, Story } from "@storybook/addon-docs";
|
||||
|
||||
import { Title, VariantRow, VariantsTable, CustomArgsTable } from "@calcom/storybook/components";
|
||||
|
||||
import ImageUploader from "./ImageUploader";
|
||||
|
||||
<Meta title="UI/ImageUploader" component={ImageUploader} />
|
||||
|
||||
<Title title="ImageUploader" suffix="Brief" subtitle="Version 1.0 — Last Update: 21 Aug 2023" />
|
||||
|
||||
## Definitions
|
||||
|
||||
`ImageUploader` is used to upload images of all image types
|
||||
|
||||
## Structure
|
||||
|
||||
Below are the props for `Image uploader`
|
||||
|
||||
<CustomArgsTable of={ImageUploader} />
|
||||
|
||||
## ImageUploader Story
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
parameters={{
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
}}
|
||||
name="ImageUploader"
|
||||
play={({ canvasElement }) => {
|
||||
const darkVariantContainer = canvasElement.querySelector("#dark-variant");
|
||||
const imageUploaderElement = darkVariantContainer.querySelector("button");
|
||||
imageUploaderElement?.addEventListener("click", () => {
|
||||
setTimeout(() => {
|
||||
document.querySelector('[role="dialog"]')?.classList.add("dark");
|
||||
}, 1);
|
||||
});
|
||||
}}
|
||||
args={{
|
||||
id: "image-1",
|
||||
buttonMsg: "upload",
|
||||
target: "target",
|
||||
handleAvatarChange: (src) => {
|
||||
console.debug(src);
|
||||
},
|
||||
}}>
|
||||
{({ id, buttonMsg, handleAvatarChange, imageSrc, target }) => (
|
||||
<VariantsTable titles={["Default"]} columnMinWidth={150}>
|
||||
<VariantRow>
|
||||
<ImageUploader
|
||||
id={id}
|
||||
buttonMsg={buttonMsg}
|
||||
handleAvatarChange={handleAvatarChange}
|
||||
imageSrc={imageSrc}
|
||||
target={target}
|
||||
/>
|
||||
</VariantRow>
|
||||
</VariantsTable>
|
||||
)}
|
||||
</Story>
|
||||
</Canvas>
|
||||
2
calcom/packages/ui/components/image-uploader/index.ts
Normal file
2
calcom/packages/ui/components/image-uploader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ImageUploader } from "./ImageUploader";
|
||||
export { default as BannerUploader } from "./BannerUploader";
|
||||
Reference in New Issue
Block a user