mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-05 09:33:07 +00:00
This update standardizes the loading indicators by replacing all instances of Loader2 with the new Spinner component. The Spinner component provides a consistent look and feel for loading states throughout the UI, enhancing the user experience. Changes include: - Updated loading indicators in various components such as popovers, modals, and views. - Ensured that the Spinner component is used with appropriate sizes for different contexts. No functional changes were made; this is purely a visual and structural improvement.
479 lines
16 KiB
TypeScript
479 lines
16 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,
|
|
} = 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;
|
|
|
|
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) {
|
|
// 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 opacity when sliders change (with persistence)
|
|
const handleCardOpacityChange = useCallback(
|
|
async (value: number[]) => {
|
|
if (!currentProject) return;
|
|
await setCardOpacity(currentProject.path, value[0]);
|
|
},
|
|
[currentProject, setCardOpacity]
|
|
);
|
|
|
|
const handleColumnOpacityChange = useCallback(
|
|
async (value: number[]) => {
|
|
if (!currentProject) return;
|
|
await setColumnOpacity(currentProject.path, value[0]);
|
|
},
|
|
[currentProject, setColumnOpacity]
|
|
);
|
|
|
|
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]
|
|
);
|
|
|
|
const handleCardBorderOpacityChange = useCallback(
|
|
async (value: number[]) => {
|
|
if (!currentProject) return;
|
|
await setCardBorderOpacity(currentProject.path, value[0]);
|
|
},
|
|
[currentProject, setCardBorderOpacity]
|
|
);
|
|
|
|
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">{cardOpacity}%</span>
|
|
</div>
|
|
<Slider
|
|
value={[cardOpacity]}
|
|
onValueChange={handleCardOpacityChange}
|
|
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">{columnOpacity}%</span>
|
|
</div>
|
|
<Slider
|
|
value={[columnOpacity]}
|
|
onValueChange={handleColumnOpacityChange}
|
|
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">{cardBorderOpacity}%</span>
|
|
</div>
|
|
<Slider
|
|
value={[cardBorderOpacity]}
|
|
onValueChange={handleCardBorderOpacityChange}
|
|
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>
|
|
);
|
|
}
|