refactor: move from next js to vite and tanstack router

This commit is contained in:
Kacper
2025-12-17 20:11:16 +01:00
parent 9954feafd8
commit 5136c32b68
263 changed files with 11148 additions and 10276 deletions

View File

@@ -0,0 +1,519 @@
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<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) {
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<string> => {
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<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) {
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 (
<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">
<Loader2 className="w-6 h-6 animate-spin text-brand-500" />
</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 ? (
<Upload className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<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>
);
}

View File

@@ -0,0 +1,315 @@
import { useState, useEffect, useRef } from "react";
import {
FolderOpen,
Folder,
ChevronRight,
Home,
ArrowLeft,
HardDrive,
CornerDownLeft,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface DirectoryEntry {
name: string;
path: string;
}
interface BrowseResult {
success: boolean;
currentPath: string;
parentPath: string | null;
directories: DirectoryEntry[];
drives?: string[];
error?: string;
warning?: string;
}
interface FileBrowserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (path: string) => void;
title?: string;
description?: string;
initialPath?: string;
}
export function FileBrowserDialog({
open,
onOpenChange,
onSelect,
title = "Select Project Directory",
description = "Navigate to your project folder or paste a path directly",
initialPath,
}: FileBrowserDialogProps) {
const [currentPath, setCurrentPath] = useState<string>("");
const [pathInput, setPathInput] = useState<string>("");
const [parentPath, setParentPath] = useState<string | null>(null);
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
const [drives, setDrives] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warning, setWarning] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const browseDirectory = async (dirPath?: string) => {
setLoading(true);
setError("");
setWarning("");
try {
// Get server URL from environment or default
const serverUrl =
import.meta.env.VITE_SERVER_URL || "http://localhost:3008";
const response = await fetch(`${serverUrl}/api/fs/browse`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dirPath }),
});
const result: BrowseResult = await response.json();
if (result.success) {
setCurrentPath(result.currentPath);
setPathInput(result.currentPath);
setParentPath(result.parentPath);
setDirectories(result.directories);
setDrives(result.drives || []);
setWarning(result.warning || "");
} else {
setError(result.error || "Failed to browse directory");
}
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load directories"
);
} finally {
setLoading(false);
}
};
// Reset current path when dialog closes
useEffect(() => {
if (!open) {
setCurrentPath("");
setPathInput("");
setParentPath(null);
setDirectories([]);
setError("");
setWarning("");
}
}, [open]);
// Load initial path or home directory when dialog opens
useEffect(() => {
if (open && !currentPath) {
browseDirectory(initialPath);
}
}, [open, initialPath]);
const handleSelectDirectory = (dir: DirectoryEntry) => {
browseDirectory(dir.path);
};
const handleGoToParent = () => {
if (parentPath) {
browseDirectory(parentPath);
}
};
const handleGoHome = () => {
browseDirectory();
};
const handleSelectDrive = (drivePath: string) => {
browseDirectory(drivePath);
};
const handleGoToPath = () => {
const trimmedPath = pathInput.trim();
if (trimmedPath) {
browseDirectory(trimmedPath);
}
};
const handlePathInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleGoToPath();
}
};
const handleSelect = () => {
if (currentPath) {
onSelect(currentPath);
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader className="pb-2">
<DialogTitle className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-brand-500" />
{title}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{description}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 min-h-[400px] flex-1 overflow-hidden py-2">
{/* Direct path input */}
<div className="flex items-center gap-2">
<Input
ref={pathInputRef}
type="text"
placeholder="Paste or type a full path (e.g., /home/user/projects/myapp)"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathInputKeyDown}
className="flex-1 font-mono text-sm"
data-testid="path-input"
disabled={loading}
/>
<Button
variant="secondary"
size="sm"
onClick={handleGoToPath}
disabled={loading || !pathInput.trim()}
data-testid="go-to-path-button"
>
<CornerDownLeft className="w-4 h-4 mr-1" />
Go
</Button>
</div>
{/* Drives selector (Windows only) */}
{drives.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
<HardDrive className="w-3 h-3" />
<span>Drives:</span>
</div>
{drives.map((drive) => (
<Button
key={drive}
variant={
currentPath.startsWith(drive) ? "default" : "outline"
}
size="sm"
onClick={() => handleSelectDrive(drive)}
className="h-7 px-3 text-xs"
disabled={loading}
>
{drive.replace("\\", "")}
</Button>
))}
</div>
)}
{/* Current path breadcrumb */}
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<Button
variant="ghost"
size="sm"
onClick={handleGoHome}
className="h-7 px-2"
disabled={loading}
>
<Home className="w-4 h-4" />
</Button>
{parentPath && (
<Button
variant="ghost"
size="sm"
onClick={handleGoToParent}
className="h-7 px-2"
disabled={loading}
>
<ArrowLeft className="w-4 h-4" />
</Button>
)}
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
{currentPath || "Loading..."}
</div>
</div>
{/* Directory list */}
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
{loading && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">
Loading directories...
</div>
</div>
)}
{error && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-destructive">{error}</div>
</div>
)}
{warning && (
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mb-2">
<div className="text-sm text-yellow-500">{warning}</div>
</div>
)}
{!loading && !error && !warning && directories.length === 0 && (
<div className="flex items-center justify-center h-full p-8">
<div className="text-sm text-muted-foreground">
No subdirectories found
</div>
</div>
)}
{!loading && !error && directories.length > 0 && (
<div className="divide-y divide-sidebar-border">
{directories.map((dir) => (
<button
key={dir.path}
onClick={() => handleSelectDirectory(dir)}
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
>
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
<span className="flex-1 truncate text-sm">{dir.name}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</button>
))}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
Paste a full path above, or click on folders to navigate. Press
Enter or click Go to jump to a path.
</div>
</div>
<DialogFooter className="border-t border-border pt-4 gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSelect} disabled={!currentPath || loading}>
<FolderOpen className="w-4 h-4 mr-2" />
Select Current Folder
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}