import { useState, useRef, useCallback, useEffect } from "react"; import { ImageIcon, Upload, Loader2, Trash2 } from "lucide-react"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { useAppStore, defaultBackgroundSettings } from "@/store/app-store"; import { getHttpApiClient } from "@/lib/http-api-client"; import { toast } from "sonner"; const ACCEPTED_IMAGE_TYPES = [ "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", ]; const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB interface BoardBackgroundModalProps { open: boolean; onOpenChange: (open: boolean) => void; } export function BoardBackgroundModal({ open, onOpenChange, }: BoardBackgroundModalProps) { const { currentProject, boardBackgroundByProject, setBoardBackground, setCardOpacity, setColumnOpacity, setColumnBorderEnabled, setCardGlassmorphism, setCardBorderEnabled, setCardBorderOpacity, setHideScrollbar, clearBoardBackground, } = useAppStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const fileInputRef = useRef(null); const [previewImage, setPreviewImage] = useState(null); // Get current background settings (live from store) const backgroundSettings = (currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings; const cardOpacity = backgroundSettings.cardOpacity; const columnOpacity = backgroundSettings.columnOpacity; const columnBorderEnabled = backgroundSettings.columnBorderEnabled; const cardGlassmorphism = backgroundSettings.cardGlassmorphism; const cardBorderEnabled = backgroundSettings.cardBorderEnabled; const cardBorderOpacity = backgroundSettings.cardBorderOpacity; const hideScrollbar = backgroundSettings.hideScrollbar; const imageVersion = backgroundSettings.imageVersion; // Update preview image when background settings change useEffect(() => { if (currentProject && backgroundSettings.imagePath) { const serverUrl = import.meta.env.VITE_SERVER_URL || "http://localhost:3008"; // Add cache-busting query parameter to force browser to reload image const cacheBuster = imageVersion ? `&v=${imageVersion}` : `&v=${Date.now()}`; const imagePath = `${serverUrl}/api/fs/image?path=${encodeURIComponent( backgroundSettings.imagePath )}&projectPath=${encodeURIComponent(currentProject.path)}${cacheBuster}`; setPreviewImage(imagePath); } else { setPreviewImage(null); } }, [currentProject, backgroundSettings.imagePath, imageVersion]); const fileToBase64 = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === "string") { resolve(reader.result); } else { reject(new Error("Failed to read file as base64")); } }; reader.onerror = () => reject(new Error("Failed to read file")); reader.readAsDataURL(file); }); }; const processFile = useCallback( async (file: File) => { if (!currentProject) { toast.error("No project selected"); return; } // Validate file type if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) { toast.error( "Unsupported file type. Please use JPG, PNG, GIF, or WebP." ); return; } // Validate file size if (file.size > DEFAULT_MAX_FILE_SIZE) { const maxSizeMB = DEFAULT_MAX_FILE_SIZE / (1024 * 1024); toast.error(`File too large. Maximum size is ${maxSizeMB}MB.`); return; } setIsProcessing(true); try { const base64 = await fileToBase64(file); // Set preview immediately setPreviewImage(base64); // Save to server const httpClient = getHttpApiClient(); const result = await httpClient.saveBoardBackground( base64, file.name, file.type, currentProject.path ); if (result.success && result.path) { // Update store with the relative path (live update) setBoardBackground(currentProject.path, result.path); toast.success("Background image saved"); } else { toast.error(result.error || "Failed to save background image"); setPreviewImage(null); } } catch (error) { console.error("Failed to process image:", error); toast.error("Failed to process image"); setPreviewImage(null); } finally { setIsProcessing(false); } }, [currentProject, setBoardBackground] ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); const files = e.dataTransfer.files; if (files.length > 0) { processFile(files[0]); } }, [processFile] ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); }, []); const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { processFile(files[0]); } // Reset the input so the same file can be selected again if (fileInputRef.current) { fileInputRef.current.value = ""; } }, [processFile] ); const handleBrowseClick = useCallback(() => { if (fileInputRef.current) { fileInputRef.current.click(); } }, []); const handleClear = useCallback(async () => { if (!currentProject) return; try { setIsProcessing(true); const httpClient = getHttpApiClient(); const result = await httpClient.deleteBoardBackground( currentProject.path ); if (result.success) { clearBoardBackground(currentProject.path); setPreviewImage(null); toast.success("Background image cleared"); } else { toast.error(result.error || "Failed to clear background image"); } } catch (error) { console.error("Failed to clear background:", error); toast.error("Failed to clear background"); } finally { setIsProcessing(false); } }, [currentProject, clearBoardBackground]); // Live update opacity when sliders change const handleCardOpacityChange = useCallback( (value: number[]) => { if (!currentProject) return; setCardOpacity(currentProject.path, value[0]); }, [currentProject, setCardOpacity] ); const handleColumnOpacityChange = useCallback( (value: number[]) => { if (!currentProject) return; setColumnOpacity(currentProject.path, value[0]); }, [currentProject, setColumnOpacity] ); const handleColumnBorderToggle = useCallback( (checked: boolean) => { if (!currentProject) return; setColumnBorderEnabled(currentProject.path, checked); }, [currentProject, setColumnBorderEnabled] ); const handleCardGlassmorphismToggle = useCallback( (checked: boolean) => { if (!currentProject) return; setCardGlassmorphism(currentProject.path, checked); }, [currentProject, setCardGlassmorphism] ); const handleCardBorderToggle = useCallback( (checked: boolean) => { if (!currentProject) return; setCardBorderEnabled(currentProject.path, checked); }, [currentProject, setCardBorderEnabled] ); const handleCardBorderOpacityChange = useCallback( (value: number[]) => { if (!currentProject) return; setCardBorderOpacity(currentProject.path, value[0]); }, [currentProject, setCardBorderOpacity] ); const handleHideScrollbarToggle = useCallback( (checked: boolean) => { if (!currentProject) return; setHideScrollbar(currentProject.path, checked); }, [currentProject, setHideScrollbar] ); if (!currentProject) { return null; } return ( Board Background Settings Set a custom background image for your kanban board and adjust card/column opacity
{/* Image Upload Section */}
{/* Hidden file input */} {/* Drop zone */}
{previewImage ? (
Background preview {isProcessing && (
)}
) : (
{isProcessing ? ( ) : ( )}

{isDragOver && !isProcessing ? "Drop image here" : "Click to upload or drag and drop"}

JPG, PNG, GIF, or WebP (max{" "} {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}MB)

)}
{/* Opacity Controls */}
{cardOpacity}%
{columnOpacity}%
{/* Column Border Toggle */}
{/* Card Glassmorphism Toggle */}
{/* Card Border Toggle */}
{/* Card Border Opacity - only show when border is enabled */} {cardBorderEnabled && (
{cardBorderOpacity}%
)} {/* Hide Scrollbar Toggle */}
); }