diff --git a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx index a51ab4902..4fb46a6d1 100644 --- a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx +++ b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx @@ -1,4 +1,3 @@ -import DOMPurify from "dompurify"; import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -14,6 +13,7 @@ import { Dropzone } from "@/components/ui/dropzone"; import { Input } from "@/components/ui/input"; import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons"; import { api } from "@/utils/api"; +import { sanitizeSvg } from "@/utils/sanitize-svg"; interface ShowIconSettingsProps { serviceId: string; @@ -89,15 +89,6 @@ export const ShowIconSettings = ({ } }; - const sanitizeSvg = (svgContent: string): string | null => { - const clean = DOMPurify.sanitize(svgContent, { - USE_PROFILES: { svg: true, svgFilters: true }, - ADD_TAGS: ["use"], - }); - if (!clean) return null; - return `data:image/svg+xml;base64,${btoa(clean)}`; - }; - const handleFileUpload = async (files: FileList | null) => { if (!files || files.length === 0) return; const file = files[0]; diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index ff0b18a30..4c5ba3e48 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -1,6 +1,7 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; -import { PenBoxIcon, Plus } from "lucide-react"; -import { useEffect, useState } from "react"; + +import { GlobeIcon, PenBoxIcon, Plus, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -14,6 +15,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { Dropzone } from "@/components/ui/dropzone"; import { Form, FormControl, @@ -24,6 +26,8 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { api } from "@/utils/api"; +import { resizeImage } from "@/utils/image-processing"; +import { sanitizeSvg } from "@/utils/sanitize-svg"; const organizationSchema = z.object({ name: z.string().min(1, { @@ -37,10 +41,24 @@ type OrganizationFormValues = z.infer; interface Props { organizationId?: string; children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; } -export function AddOrganization({ organizationId }: Props) { - const [open, setOpen] = useState(false); +export function AddOrganization({ + organizationId, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, +}: Props) { + const [internalOpen, setInternalOpen] = useState(false); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const uploadCounter = useRef(0); + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled + ? controlledOnOpenChange || (() => {}) + : setInternalOpen; const utils = api.useUtils(); const { data: organization } = api.organization.one.useQuery( { @@ -65,14 +83,18 @@ export function AddOrganization({ organizationId }: Props) { useEffect(() => { if (organization) { + uploadCounter.current++; + setIsUploading(false); form.reset({ name: organization.name, logo: organization.logo || "", }); + setUploadedFileName(null); } }, [organization, form]); const onSubmit = async (values: OrganizationFormValues) => { + if (isUploading) return; await mutateAsync({ name: values.name, logo: values.logo, @@ -80,6 +102,7 @@ export function AddOrganization({ organizationId }: Props) { }) .then(() => { form.reset(); + setUploadedFileName(null); toast.success( `Organization ${organizationId ? "updated" : "created"} successfully`, ); @@ -99,8 +122,94 @@ export function AddOrganization({ organizationId }: Props) { }); }; + const handleFileUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + if (!file) return; + + const currentUploadId = ++uploadCounter.current; + setIsUploading(true); + + const allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + "image/webp", + ]; + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + const allowedExtensions = ["jpg", "jpeg", "png", "svg", "webp"]; + + if ( + !allowedTypes.includes(file.type) && + !allowedExtensions.includes(fileExtension || "") + ) { + toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed"); + setIsUploading(false); + return; + } + + if (file.size > 2 * 1024 * 1024) { + toast.error("Image size must be less than 2MB"); + setIsUploading(false); + return; + } + + const isSvg = file.type === "image/svg+xml" || fileExtension === "svg"; + + if (isSvg) { + try { + const text = await file.text(); + const sanitizedDataUrl = sanitizeSvg(text); + if (currentUploadId !== uploadCounter.current) return; + if (!sanitizedDataUrl) { + toast.error("Invalid SVG file"); + return; + } + form.setValue("logo", sanitizedDataUrl); + form.trigger("logo"); + setUploadedFileName(file.name); + } catch (error) { + if (currentUploadId === uploadCounter.current) { + toast.error("Error processing SVG"); + } + } finally { + if (currentUploadId === uploadCounter.current) { + setIsUploading(false); + } + } + return; + } + + // Resize raster images to max 256x256 and convert to WebP to save space + try { + const resizedDataUrl = await resizeImage(file, 256); + if (currentUploadId !== uploadCounter.current) return; + form.setValue("logo", resizedDataUrl); + form.trigger("logo"); + setUploadedFileName(file.name); + } catch (error) { + if (currentUploadId === uploadCounter.current) { + toast.error("Error processing image"); + } + } finally { + if (currentUploadId === uploadCounter.current) { + setIsUploading(false); + } + } + }; + return ( - + { + if (!val) { + uploadCounter.current++; + setIsUploading(false); + } + setOpen(val); + }} + > {organizationId ? ( + )} + + + + + + + + ); + }} /> - diff --git a/apps/dokploy/components/ui/dropzone.tsx b/apps/dokploy/components/ui/dropzone.tsx index 6d9c05ef6..5ae7c9d3e 100644 --- a/apps/dokploy/components/ui/dropzone.tsx +++ b/apps/dokploy/components/ui/dropzone.tsx @@ -10,13 +10,24 @@ interface DropzoneProps "value" | "onChange" > { classNameWrapper?: string; + classNameContent?: string; className?: string; dropMessage: string; onChange: (acceptedFiles: FileList | null) => void; } export const Dropzone = React.forwardRef( - ({ className, classNameWrapper, dropMessage, onChange, ...props }, ref) => { + ( + { + className, + classNameWrapper, + classNameContent, + dropMessage, + onChange, + ...props + }, + ref, + ) => { const inputRef = useRef(null); // Function to handle drag over event const handleDragOver = (e: React.DragEvent) => { @@ -51,7 +62,10 @@ export const Dropzone = React.forwardRef( )} > => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + + if (width > maxSize || height > maxSize) { + if (width > height) { + height = Math.round((height * maxSize) / width); + width = maxSize; + } else { + width = Math.round((width * maxSize) / height); + height = maxSize; + } + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + resolve(event.target?.result as string); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL("image/webp", 0.8)); + }; + img.onerror = reject; + img.src = event.target?.result as string; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +}; diff --git a/apps/dokploy/utils/sanitize-svg.ts b/apps/dokploy/utils/sanitize-svg.ts new file mode 100644 index 000000000..b3474e7bc --- /dev/null +++ b/apps/dokploy/utils/sanitize-svg.ts @@ -0,0 +1,18 @@ +import DOMPurify from "dompurify"; + +export const sanitizeSvg = (svgContent: string): string | null => { + const clean = DOMPurify.sanitize(svgContent, { + USE_PROFILES: { svg: true, svgFilters: true }, + }); + + if (!clean) return null; + + // Fix unicode base64 bug (TextEncoder byte-loop handles non-Latin1 chars) + const bytes = new TextEncoder().encode(clean); + let binString = ""; + for (let i = 0; i < bytes.length; i++) { + binString += String.fromCharCode(bytes[i]!); + } + + return `data:image/svg+xml;base64,${btoa(binString)}`; +};