From b3919be6286cfc19b52fcca90150efe6938b67f1 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 4 Apr 2026 20:25:47 -0600 Subject: [PATCH] feat: enhance ShowIconSettings component with dialog and file upload functionality - Updated the ShowIconSettings component to include a dialog for icon selection and upload. - Added functionality to handle file uploads, including validation for file types and sizes. - Implemented icon removal feature within the dialog. - Refactored icon selection logic to improve user experience and maintainability. - Adjusted the application page to integrate the updated ShowIconSettings component. --- .../application/icon/show-icon-settings.tsx | 401 +++++++++--------- .../services/application/[applicationId].tsx | 26 +- 2 files changed, 212 insertions(+), 215 deletions(-) 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 894d3beb4..ed2bd2675 100644 --- a/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx +++ b/apps/dokploy/components/dashboard/application/icon/show-icon-settings.tsx @@ -1,8 +1,15 @@ import DOMPurify from "dompurify"; -import { Search, X } from "lucide-react"; +import { GlobeIcon, Pencil, Search, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Dropzone } from "@/components/ui/dropzone"; import { Input } from "@/components/ui/input"; import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons"; @@ -10,6 +17,7 @@ import { api } from "@/utils/api"; interface ShowIconSettingsProps { applicationId: string; + icon?: string | null; } const svgToDataUrl = (icon: BundledIcon): string => { @@ -17,8 +25,11 @@ const svgToDataUrl = (icon: BundledIcon): string => { return `data:image/svg+xml;base64,${btoa(svg)}`; }; -export const ShowIconSettings = ({ applicationId }: ShowIconSettingsProps) => { - const [uploadedIcon, setUploadedIcon] = useState(null); +export const ShowIconSettings = ({ + applicationId, + icon, +}: ShowIconSettingsProps) => { + const [open, setOpen] = useState(false); const [iconSearchQuery, setIconSearchQuery] = useState(""); const [iconsToShow, setIconsToShow] = useState(24); @@ -26,50 +37,53 @@ export const ShowIconSettings = ({ applicationId }: ShowIconSettingsProps) => { if (!iconSearchQuery) return bundledIcons; const q = iconSearchQuery.toLowerCase(); return bundledIcons.filter( - (icon) => - icon.title.toLowerCase().includes(q) || - icon.slug.toLowerCase().includes(q), + (i) => + i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q), ); }, [iconSearchQuery]); const displayedIcons = filteredIcons.slice(0, iconsToShow); const hasMoreIcons = filteredIcons.length > iconsToShow; - const { data } = api.application.one.useQuery( - { applicationId }, - { refetchInterval: 5000 }, - ); const utils = api.useUtils(); const { mutateAsync: updateApplication } = api.application.update.useMutation(); useEffect(() => { - if (data?.icon) { - setUploadedIcon(data.icon); - } else { - setUploadedIcon(null); + if (open) { + setIconSearchQuery(""); + setIconsToShow(24); } - }, [data?.icon]); + }, [open]); - useEffect(() => { - setIconsToShow(24); - }, [iconSearchQuery]); - - const handleIconSelect = async (icon: BundledIcon) => { + const handleIconSelect = async (selectedIcon: BundledIcon) => { try { - const dataUrl = svgToDataUrl(icon); - setUploadedIcon(dataUrl); + const dataUrl = svgToDataUrl(selectedIcon); await updateApplication({ applicationId, icon: dataUrl, }); toast.success("Icon saved successfully"); await utils.application.one.invalidate({ applicationId }); + setOpen(false); } catch (_error) { toast.error("Error saving icon"); } }; + const handleRemoveIcon = async () => { + try { + await updateApplication({ + applicationId, + icon: null, + }); + toast.success("Icon removed"); + await utils.application.one.invalidate({ applicationId }); + } catch (_error) { + toast.error("Error removing icon"); + } + }; + const sanitizeSvg = (svgContent: string): string | null => { const clean = DOMPurify.sanitize(svgContent, { USE_PROFILES: { svg: true, svgFilters: true }, @@ -79,190 +93,185 @@ export const ShowIconSettings = ({ applicationId }: ShowIconSettingsProps) => { return `data:image/svg+xml;base64,${btoa(clean)}`; }; + const handleFileUpload = async (files: FileList | null) => { + if (!files || files.length === 0) return; + const file = files[0]; + if (!file) return; + + const allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + ]; + const fileExtension = file.name.split(".").pop()?.toLowerCase(); + const allowedExtensions = ["jpg", "jpeg", "png", "svg"]; + + if ( + !allowedTypes.includes(file.type) && + !allowedExtensions.includes(fileExtension || "") + ) { + toast.error("Only JPG, JPEG, PNG, and SVG files are allowed"); + return; + } + + if (file.size > 2 * 1024 * 1024) { + toast.error("Image size must be less than 2MB"); + return; + } + + const isSvg = file.type === "image/svg+xml" || fileExtension === "svg"; + + if (isSvg) { + const text = await file.text(); + const sanitizedDataUrl = sanitizeSvg(text); + if (!sanitizedDataUrl) { + toast.error("Invalid SVG file"); + return; + } + try { + await updateApplication({ + applicationId, + icon: sanitizedDataUrl, + }); + toast.success("Icon saved!"); + await utils.application.one.invalidate({ applicationId }); + setOpen(false); + } catch (_error) { + toast.error("Error saving icon"); + } + return; + } + + const reader = new FileReader(); + reader.onload = async (event) => { + const result = event.target?.result as string; + try { + await updateApplication({ + applicationId, + icon: result, + }); + toast.success("Icon saved!"); + await utils.application.one.invalidate({ applicationId }); + setOpen(false); + } catch (_error) { + toast.error("Error saving icon"); + } + }; + reader.readAsDataURL(file); + }; + return ( -
- {uploadedIcon && ( -
- {/* biome-ignore lint/performance/noImgElement: icon is data URL */} - Uploaded icon -
-

Icon uploaded

-

- This icon will appear in service cards -

-
- -
- )} - -
-
- - setIconSearchQuery(e.target.value)} - className="pl-9" - /> -
- -
- {displayedIcons.length === 0 ? ( -
- No icons found -
+ + + - ))} -
- {hasMoreIcons && ( -
- -
- )} - + )} -
+
+ +
+ + + + + + Change Icon + {icon && ( + + )} + + -
-

- or upload a custom icon -

-
+
+
+ + setIconSearchQuery(e.target.value)} + className="pl-9" + /> +
+ +
+ {displayedIcons.length === 0 ? ( +
+ No icons found +
+ ) : ( + <> +
+ {displayedIcons.map((i) => ( + + ))} +
+ {hasMoreIcons && ( +
+ +
+ )} + + )} +
+ +
+

+ or upload a custom icon +

{ - if (!files || files.length === 0) return; - const file = files[0]; - if (!file) return; - - const allowedTypes = [ - "image/jpeg", - "image/jpg", - "image/png", - "image/svg+xml", - ]; - const fileExtension = file.name.split(".").pop()?.toLowerCase(); - const allowedExtensions = ["jpg", "jpeg", "png", "svg"]; - - if ( - !allowedTypes.includes(file.type) && - !allowedExtensions.includes(fileExtension || "") - ) { - toast.error("Only JPG, JPEG, PNG, and SVG files are allowed"); - return; - } - - if (file.size > 2 * 1024 * 1024) { - toast.error("Image size must be less than 2MB"); - return; - } - - const isSvg = - file.type === "image/svg+xml" || fileExtension === "svg"; - - if (isSvg) { - const text = await file.text(); - const sanitizedDataUrl = sanitizeSvg(text); - if (!sanitizedDataUrl) { - toast.error("Invalid SVG file"); - return; - } - setUploadedIcon(sanitizedDataUrl); - try { - await updateApplication({ - applicationId, - icon: sanitizedDataUrl, - }); - toast.success("Icon saved!"); - await utils.application.one.invalidate({ - applicationId, - }); - } catch (_error) { - toast.error("Error saving icon"); - setUploadedIcon(null); - } - return; - } - - const reader = new FileReader(); - reader.onload = async (event) => { - const result = event.target?.result as string; - setUploadedIcon(result); - try { - await updateApplication({ - applicationId, - icon: result, - }); - toast.success("Icon saved!"); - await utils.application.one.invalidate({ - applicationId, - }); - } catch (_error) { - toast.error("Error saving icon"); - setUploadedIcon(null); - } - }; - reader.readAsDataURL(file); - }} + onChange={handleFileUpload} classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg" /> -
-
- Supported formats: JPG, JPEG, PNG, SVG (max 2MB) +
+ Supported formats: JPG, JPEG, PNG, SVG (max 2MB) +
-
-
+
+ ); }; diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx index 322ea36a7..be4518c7b 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx @@ -1,7 +1,7 @@ import { validateRequest } from "@dokploy/server/lib/auth"; import { createServerSideHelpers } from "@trpc/react-query/server"; import copy from "copy-to-clipboard"; -import { GlobeIcon, HelpCircle, ServerOff } from "lucide-react"; +import { HelpCircle, ServerOff } from "lucide-react"; import type { GetServerSidePropsContext, InferGetServerSidePropsType, @@ -25,12 +25,12 @@ import { ShowDeployments } from "@/components/dashboard/application/deployments/ import { ShowDomains } from "@/components/dashboard/application/domains/show-domains"; import { ShowEnvironment } from "@/components/dashboard/application/environment/show"; import { ShowGeneralApplication } from "@/components/dashboard/application/general/show"; +import { ShowIconSettings } from "@/components/dashboard/application/icon/show-icon-settings"; import { ShowDockerLogs } from "@/components/dashboard/application/logs/show"; import { ShowPatches } from "@/components/dashboard/application/patches/show-patches"; import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments"; import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules"; import { UpdateApplication } from "@/components/dashboard/application/update-application"; -import { ShowIconSettings } from "@/components/dashboard/application/icon/show-icon-settings"; import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups"; import { DeleteService } from "@/components/dashboard/compose/delete-service"; import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring"; @@ -39,7 +39,6 @@ import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb"; import { StatusTooltip } from "@/components/shared/status-tooltip"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -125,20 +124,13 @@ const Service = (
-
+ +
- - {data?.icon ? ( - // biome-ignore lint/performance/noImgElement: icon is data URL or base64; Next/Image not suited for dynamic inline icons - {data.name} - ) : ( - - )}
{data?.name} @@ -281,7 +273,6 @@ const Service = ( {permissions?.service.create && ( Advanced )} - Icon
@@ -431,9 +422,6 @@ const Service = (
)} - - - )}