Files
automaker/apps/ui/src/components/dialogs/board-background-modal.tsx
Shirone 69ff8df7c1 feat(ui): enhance BoardBackgroundModal with local state management for opacity sliders
- Implemented local state for card, column, and card border opacity during slider dragging to improve user experience.
- Added useEffect to sync local state with store settings when not dragging.
- Updated handlers to commit changes to the store and persist settings upon slider release.
- Adjusted UI to reflect local state values for opacity sliders, ensuring immediate feedback during adjustments.
2026-01-20 23:58:00 +01:00

532 lines
18 KiB
TypeScript

import { useState, useRef, useCallback, useEffect } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ImageIcon, Upload, Trash2 } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
const logger = createLogger('BoardBackgroundModal');
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 { getAuthenticatedImageUrl } from '@/lib/api-fetch';
import { useBoardBackgroundSettings } from '@/hooks/use-board-background-settings';
import { toast } from 'sonner';
import {
fileToBase64,
validateImageFile,
ACCEPTED_IMAGE_TYPES,
DEFAULT_MAX_FILE_SIZE,
} from '@/lib/image-utils';
interface BoardBackgroundModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModalProps) {
const { currentProject, boardBackgroundByProject } = useAppStore();
const {
setBoardBackground,
setCardOpacity,
setColumnOpacity,
setColumnBorderEnabled,
setCardGlassmorphism,
setCardBorderEnabled,
setCardBorderOpacity,
setHideScrollbar,
clearBoardBackground,
persistSettings,
getCurrentSettings,
} = useBoardBackgroundSettings();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
// Get current background settings (live from store)
const backgroundSettings =
(currentProject && boardBackgroundByProject[currentProject.path]) || defaultBackgroundSettings;
// Local state for sliders during dragging (avoids store updates during drag)
const [localCardOpacity, setLocalCardOpacity] = useState(backgroundSettings.cardOpacity);
const [localColumnOpacity, setLocalColumnOpacity] = useState(backgroundSettings.columnOpacity);
const [localCardBorderOpacity, setLocalCardBorderOpacity] = useState(
backgroundSettings.cardBorderOpacity
);
const [isDragging, setIsDragging] = useState(false);
// Sync local state with store when not dragging (e.g., on modal open or external changes)
useEffect(() => {
if (!isDragging) {
setLocalCardOpacity(backgroundSettings.cardOpacity);
setLocalColumnOpacity(backgroundSettings.columnOpacity);
setLocalCardBorderOpacity(backgroundSettings.cardBorderOpacity);
}
}, [
isDragging,
backgroundSettings.cardOpacity,
backgroundSettings.columnOpacity,
backgroundSettings.cardBorderOpacity,
]);
const columnBorderEnabled = backgroundSettings.columnBorderEnabled;
const cardGlassmorphism = backgroundSettings.cardGlassmorphism;
const cardBorderEnabled = backgroundSettings.cardBorderEnabled;
const hideScrollbar = backgroundSettings.hideScrollbar;
const imageVersion = backgroundSettings.imageVersion;
// Update preview image when background settings change
useEffect(() => {
if (currentProject && backgroundSettings.imagePath) {
// Add cache-busting query parameter to force browser to reload image
const cacheBuster = imageVersion ?? Date.now().toString();
const imagePath = getAuthenticatedImageUrl(
backgroundSettings.imagePath,
currentProject.path,
cacheBuster
);
setPreviewImage(imagePath);
} else {
setPreviewImage(null);
}
}, [currentProject, backgroundSettings.imagePath, imageVersion]);
const processFile = useCallback(
async (file: File) => {
if (!currentProject) {
toast.error('No project selected');
return;
}
// Validate file
const validation = validateImageFile(file, DEFAULT_MAX_FILE_SIZE);
if (!validation.isValid) {
toast.error(validation.error);
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 and persist to server
await setBoardBackground(currentProject.path, result.path);
toast.success('Background image saved');
} else {
toast.error(result.error || 'Failed to save background image');
setPreviewImage(null);
}
} catch (error) {
logger.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<HTMLInputElement>) => {
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) {
await clearBoardBackground(currentProject.path);
setPreviewImage(null);
toast.success('Background image cleared');
} else {
toast.error(result.error || 'Failed to clear background image');
}
} catch (error) {
logger.error('Failed to clear background:', error);
toast.error('Failed to clear background');
} finally {
setIsProcessing(false);
}
}, [currentProject, clearBoardBackground]);
// Live update local state during drag (modal-only, no store update)
const handleCardOpacityChange = useCallback((value: number[]) => {
setIsDragging(true);
setLocalCardOpacity(value[0]);
}, []);
// Update store and persist when slider is released
const handleCardOpacityCommit = useCallback(
(value: number[]) => {
if (!currentProject) return;
setIsDragging(false);
setCardOpacity(currentProject.path, value[0]);
const current = getCurrentSettings(currentProject.path);
persistSettings(currentProject.path, { ...current, cardOpacity: value[0] });
},
[currentProject, setCardOpacity, getCurrentSettings, persistSettings]
);
// Live update local state during drag (modal-only, no store update)
const handleColumnOpacityChange = useCallback((value: number[]) => {
setIsDragging(true);
setLocalColumnOpacity(value[0]);
}, []);
// Update store and persist when slider is released
const handleColumnOpacityCommit = useCallback(
(value: number[]) => {
if (!currentProject) return;
setIsDragging(false);
setColumnOpacity(currentProject.path, value[0]);
const current = getCurrentSettings(currentProject.path);
persistSettings(currentProject.path, { ...current, columnOpacity: value[0] });
},
[currentProject, setColumnOpacity, getCurrentSettings, persistSettings]
);
const handleColumnBorderToggle = useCallback(
async (checked: boolean) => {
if (!currentProject) return;
await setColumnBorderEnabled(currentProject.path, checked);
},
[currentProject, setColumnBorderEnabled]
);
const handleCardGlassmorphismToggle = useCallback(
async (checked: boolean) => {
if (!currentProject) return;
await setCardGlassmorphism(currentProject.path, checked);
},
[currentProject, setCardGlassmorphism]
);
const handleCardBorderToggle = useCallback(
async (checked: boolean) => {
if (!currentProject) return;
await setCardBorderEnabled(currentProject.path, checked);
},
[currentProject, setCardBorderEnabled]
);
// Live update local state during drag (modal-only, no store update)
const handleCardBorderOpacityChange = useCallback((value: number[]) => {
setIsDragging(true);
setLocalCardBorderOpacity(value[0]);
}, []);
// Update store and persist when slider is released
const handleCardBorderOpacityCommit = useCallback(
(value: number[]) => {
if (!currentProject) return;
setIsDragging(false);
setCardBorderOpacity(currentProject.path, value[0]);
const current = getCurrentSettings(currentProject.path);
persistSettings(currentProject.path, { ...current, cardBorderOpacity: value[0] });
},
[currentProject, setCardBorderOpacity, getCurrentSettings, persistSettings]
);
const handleHideScrollbarToggle = useCallback(
async (checked: boolean) => {
if (!currentProject) return;
await setHideScrollbar(currentProject.path, checked);
},
[currentProject, setHideScrollbar]
);
if (!currentProject) {
return null;
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-md overflow-y-auto">
<SheetHeader className="px-6 pt-6">
<SheetTitle className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-brand-500" />
Board Background Settings
</SheetTitle>
<SheetDescription className="text-muted-foreground">
Set a custom background image for your kanban board and adjust card/column opacity
</SheetDescription>
</SheetHeader>
<div className="space-y-6 px-6 pb-6">
{/* Image Upload Section */}
<div className="space-y-3">
<Label>Background Image</Label>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
disabled={isProcessing}
/>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
'relative rounded-lg border-2 border-dashed transition-all duration-200',
{
'border-brand-500/60 bg-brand-500/5 dark:bg-brand-500/10':
isDragOver && !isProcessing,
'border-muted-foreground/25': !isDragOver && !isProcessing,
'border-muted-foreground/10 opacity-50 cursor-not-allowed': isProcessing,
'hover:border-brand-500/40 hover:bg-brand-500/5 dark:hover:bg-brand-500/5':
!isProcessing && !isDragOver,
}
)}
>
{previewImage ? (
<div className="relative p-4">
<div className="relative w-full h-48 rounded-md overflow-hidden border border-border bg-muted">
<img
src={previewImage}
alt="Background preview"
className="w-full h-full object-cover"
/>
{isProcessing && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Spinner size="lg" />
</div>
)}
</div>
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={handleBrowseClick}
disabled={isProcessing}
className="flex-1"
>
<Upload className="w-4 h-4 mr-2" />
Change Image
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleClear}
disabled={isProcessing}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear
</Button>
</div>
</div>
) : (
<div
onClick={handleBrowseClick}
className="flex flex-col items-center justify-center p-8 text-center cursor-pointer"
>
<div
className={cn(
'rounded-full p-3 mb-3',
isDragOver && !isProcessing
? 'bg-brand-500/10 dark:bg-brand-500/20'
: 'bg-muted'
)}
>
{isProcessing ? (
<Spinner size="lg" />
) : (
<ImageIcon className="h-6 w-6 text-muted-foreground" />
)}
</div>
<p className="text-sm text-muted-foreground">
{isDragOver && !isProcessing
? 'Drop image here'
: 'Click to upload or drag and drop'}
</p>
<p className="text-xs text-muted-foreground mt-1">
JPG, PNG, GIF, or WebP (max {Math.round(DEFAULT_MAX_FILE_SIZE / (1024 * 1024))}
MB)
</p>
</div>
)}
</div>
</div>
{/* Opacity Controls */}
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Card Opacity</Label>
<span className="text-sm text-muted-foreground">{localCardOpacity}%</span>
</div>
<Slider
value={[localCardOpacity]}
onValueChange={handleCardOpacityChange}
onValueCommit={handleCardOpacityCommit}
min={0}
max={100}
step={1}
className="w-full"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Column Opacity</Label>
<span className="text-sm text-muted-foreground">{localColumnOpacity}%</span>
</div>
<Slider
value={[localColumnOpacity]}
onValueChange={handleColumnOpacityChange}
onValueCommit={handleColumnOpacityCommit}
min={0}
max={100}
step={1}
className="w-full"
/>
</div>
{/* Column Border Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="column-border-toggle"
checked={columnBorderEnabled}
onCheckedChange={handleColumnBorderToggle}
/>
<Label htmlFor="column-border-toggle" className="cursor-pointer">
Show Column Borders
</Label>
</div>
{/* Card Glassmorphism Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="card-glassmorphism-toggle"
checked={cardGlassmorphism}
onCheckedChange={handleCardGlassmorphismToggle}
/>
<Label htmlFor="card-glassmorphism-toggle" className="cursor-pointer">
Card Glassmorphism (blur effect)
</Label>
</div>
{/* Card Border Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="card-border-toggle"
checked={cardBorderEnabled}
onCheckedChange={handleCardBorderToggle}
/>
<Label htmlFor="card-border-toggle" className="cursor-pointer">
Show Card Borders
</Label>
</div>
{/* Card Border Opacity - only show when border is enabled */}
{cardBorderEnabled && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Card Border Opacity</Label>
<span className="text-sm text-muted-foreground">{localCardBorderOpacity}%</span>
</div>
<Slider
value={[localCardBorderOpacity]}
onValueChange={handleCardBorderOpacityChange}
onValueCommit={handleCardBorderOpacityCommit}
min={0}
max={100}
step={1}
className="w-full"
/>
</div>
)}
{/* Hide Scrollbar Toggle */}
<div className="flex items-center gap-2">
<Checkbox
id="hide-scrollbar-toggle"
checked={hideScrollbar}
onCheckedChange={handleHideScrollbarToggle}
/>
<Label htmlFor="hide-scrollbar-toggle" className="cursor-pointer">
Hide Board Scrollbar
</Label>
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}