I am desperate. I am building NextJS app with AdonisJS backend. This app will work like warehouse planner, user should be able to put some elements like floor, wall etc. on grid and move it around, resize, put together with another elements etc. It was working well until I came to request from my user to add import of dwg files. I should be able to upload dwg file and then use it like another elements on the grid, so I should be able to move it, resize it etc. I was able to do some work, I can upload that dwg file to my backend, which respond with svg, but I am unable to display content of that svg on my grid, just something like this
I am sharing my frontend code and also my backend code, I hope it is even possible to do this.
Btw, dwg files are aroung 5MB.
import_controller.ts (backend)
const execFileAsync = promisify(execFile)
export default class ImportController {
public async importDwg({ request, response }: HttpContext) {
try {
const file = request.file('file')
if (!file) return response.badRequest({ message: 'No file uploaded' })
const tmpDir = path.join(process.cwd(), 'tmp')
await fs.mkdir(tmpDir, { recursive: true })
const timestamp = Date.now()
const dwgPath = path.join(tmpDir, `${timestamp}_${file.clientName}`)
await file.move(tmpDir, { name: path.basename(dwgPath) })
// Convert DWG to SVG
const dwg2svgPath = '/usr/local/bin/dwg2svg'
const { stdout, stderr } = await execFileAsync(dwg2svgPath, [dwgPath], {
maxBuffer: 1024 * 1024 * 64, // 64 MB
})
console.log(`dwg2svg stderr: ${stderr}`)
let svgString = stdout
// Fix malformed width/height line
svgString = svgString.replace(/width="([^"]+)\s+height="([^"]+)"/, 'width="$1" height="$2"');
// Ensure viewBox exists
const viewBoxMatch = svgString.match(/viewBox="([^"]+)"/)
if (!viewBoxMatch) {
const width = svgString.match(/width="([^"]+)"/)?.[1] ?? '2000'
const height = svgString.match(/height="([^"]+)"/)?.[1] ?? '1000'
// svgString = svgString.replace(/<svg([^>]*)>/, `<svg$1 viewBox="0 0 ${width} ${height}">`)
}
// Constrain to iframe view
svgString = svgString
.replace(/width="[^"]+"/, 'width="100%"')
.replace(/height="[^"]+"/, 'height="100%"')
await fs.writeFile(path.join(tmpDir, 'debug_output.svg'), svgString)
const base64Svg = `data:image/svg+xml;base64,${Buffer.from(svgString).toString('base64')}`
return response.ok({
message: 'DWG converted successfully',
image: base64Svg,
elements: [
{
id: Date.now(),
type: 'dwg',
x: 0,
y: 0,
width: 800,
height: 600,
content: base64Svg,
originalFileName: file.clientName,
}
]
})
} catch (error: any) {
console.error('Error importing DWG:', error)
return response.internalServerError({
message: 'Failed to import DWG',
error: error.message,
})
}
}
}
Here is my warehouse-grid.tsx file
"use client";
export const WarehouseGrid = forwardRef<
{ fitAllElementsToView: () => void },
WarehouseGridProps
>(function WarehouseGrid(
{
selectedTool,
setSelectedTool,
lang = "sk",
is3DMode,
elements,
onElementsChange,
}: WarehouseGridProps,
ref
) {
return (
<div className="flex flex-col h-full">
{/* Controls section - always visible in both modes */}
<div className="flex items-center gap-4 p-2 bg-white border-b">
<div className="flex items-center gap-2">
<Button
variant={selectedTool === "move" ? "default" : "ghost"}
onClick={() => setSelectedTool("move")}
size="icon"
title={t(lang, "moveTool")}
>
<Move className="h-4 w-4" />
</Button>
<Button
variant={selectedTool === "resize" ? "default" : "ghost"}
onClick={() => setSelectedTool("resize")}
size="icon"
title={t(lang, "resizeTool")}
>
<Maximize className="h-4 w-4" />
</Button>
<Button
variant={selectedTool === "pan" ? "default" : "ghost"}
onClick={() => setSelectedTool("pan")}
size="icon"
title={t(lang, "panTool")}
>
<Hand className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Button
variant="outline"
size="icon"
title={t(lang, "zoomOut")}
onClick={handleZoomOut}
>
<Minus className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
title={t(lang, "zoomIn")}
onClick={handleZoomIn}
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
title={t(lang, "resetZoom")}
onClick={handleZoomReset}
>
<Maximize2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={t(lang, "fitToScreen")}
onClick={fitAllElementsToView}
>
<Maximize2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Main content area */}
<div className="flex-1 relative">
{is3DMode ? (
// 3D Mode
<div ref={containerRef} className="w-full h-full" />
) : (
// 2D Mode
<div className="flex h-full">
{/* Grid container */}
<div
ref={gridRef}
className={cn(
"relative flex-1 bg-gray-50 overflow-hidden",
{
"cursor-grab": selectedTool === "pan",
"cursor-grabbing": isDraggingViewport,
"cursor-default": selectedTool === "move" || selectedTool === "resize",
}
)}
onClick={handleGridClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div
style={{
transform: translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale}),
transformOrigin: "0 0",
width: "100%",
height: "100%",
position: "absolute",
}}
>
{renderGrid()}
{elements.map((element) => {
return (
<DraggableWithoutFindDOMNode
key={element.id}
position={{ x: element.x, y: element.y }}
onStop={(e, data) => handleDragStop(element.id, e, data)}
bounds="parent"
disabled={selectedTool !== "move"}
handle=".drag-handle"
>
<ResizableBox
width={element.width}
height={element.height}
onResizeStop={(e, data) => handleResizeStop(element.id, e, data)}
draggableOpts={{ disabled: selectedTool !== "resize" }}
handle={
<div className="absolute right-0 bottom-0 w-4 h-4 bg-blue-500 cursor-se-resize rounded-bl" />
}
>
<div
className={cn(
"relative border-2 drag-handle",
selectedElementIds.includes(element.id)
? "border-blue-500"
: "border-gray-200"
)}
onClick={(e) => handleElementClick(e, element.id)}
>
{renderElement(element)}
</div>
</ResizableBox>
</DraggableWithoutFindDOMNode>
);
})}
</div>
</div>
{/* Properties panel */}
{selectedElementIds.length === 1 && (
<div className="w-80 border-l border-gray-200">
<ElementProperties
selectedElement={elements.find((el) => el.id === selectedElementIds[0]) || null}
onUpdate={handleUpdateElement}
onDelete={handleDeleteElement}
lang={lang}
/>
</div>
)}
</div>
)}
</div>
{/* Language selector */}
<div className="fixed bottom-0 right-0 p-4">
<LanguageSelector lang={lang} />
</div>
</div>
);
});
WarehouseGrid.displayName = "WarehouseGrid";
export default WarehouseGrid;
And to be complete, here is my page.tsx, from where I call warehouse-grid.
"use client";
export default function DashboardPage() {
const router = useRouter();
const { isLoading, isAuthenticated } = useAuth();
const [selectedTool, setSelectedTool] = useState("select");
const [activeCategory, setActiveCategory] = useState<ElementCategory>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [language, setLanguage] = useState("sk"); // Default to "en"
const [showWarehouseDialog, setShowWarehouseDialog] = useState(true);
// eslint-disable-next-line
const [gridElements, setGridElements] = useState<any[]>([]);
const [is3DMode, setIs3DMode] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
const [showSaveWarehouseDialog, setShowSaveWarehouseDialog] = useState(false);
const [showLoadWarehouseDialog, setShowLoadWarehouseDialog] = useState(false);
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
const [pendingAction, setPendingAction] = useState<'new' | 'load' | null>(null);
// new
const [feUrn, setFeUrn] = useState<string | null>(null);
// Add a ref to access the fitAllElementsToView function
const warehouseGridRef = useRef<{ fitAllElementsToView: () => void } | null>(null);
const handleNewWarehouse = useCallback(() => {
const createNew = () => {
setGridElements([]);
setHasUnsavedChanges(false);
setShowWarehouseDialog(false);
};
if (hasUnsavedChanges) {
setShowUnsavedChangesDialog(true);
setPendingAction('new');
} else {
createNew();
}
}, [hasUnsavedChanges]);
// Update the handleUnsavedChangesAction callback
const handleUnsavedChangesAction = useCallback((action: 'save' | 'discard' | 'cancel') => {
setShowUnsavedChangesDialog(false);
if (action === 'save') {
setShowSaveWarehouseDialog(true);
} else if (action === 'discard') {
if (pendingAction === 'new') {
setGridElements([]);
setHasUnsavedChanges(false);
setShowWarehouseDialog(false);
} else if (pendingAction === 'load') {
setShowLoadWarehouseDialog(true);
}
setPendingAction(null);
}
// Always clear the pending action if cancelled
if (action === 'cancel') {
setPendingAction(null);
}
}, [pendingAction]);
// Handle language cookie
useEffect(() => {
const langCookie = getCookie("lang");
setLanguage(langCookie?.toString() || "en");
}, []);
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
const message = t(language, "unsavedChangesWarning");
e.preventDefault();
e.returnValue = message;
return message;
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [hasUnsavedChanges, language]);
useEffect(() => {
return () => {
// Prompt for unsaved changes when component unmounts
if (hasUnsavedChanges) {
const shouldSave = window.confirm(t(language, "unsavedChangesWarning"));
if (shouldSave) {
// You might want to handle auto-saving here
console.log('Auto-saving on unmount...');
}
}
};
}, [hasUnsavedChanges, language]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (!isAuthenticated) {
router.replace("/login");
return null;
}
const handleLoadWarehouseClick = () => {
setShowLoadWarehouseDialog(true);
};
const handleSaveWarehouse = () => {
setShowSaveWarehouseDialog(true)
}
const handleLoadWarehouse = () => {
if (hasUnsavedChanges) {
setShowUnsavedChangesDialog(true)
setPendingAction('load')
} else {
setShowLoadWarehouseDialog(true)
}
}
// eslint-disable-next-line
const handleLoadWarehouseData = (data: any) => {
console.log("handleLoadWarehouseData called with data:", data);
if (!data || !Array.isArray(data)) {
console.error("Invalid warehouse data received:", data);
toast.error(t(language, 'error_loading_warehouse'));
return;
}
setGridElements(data)
setHasUnsavedChanges(false)
// Center the view on the loaded elements after a short delay
setTimeout(() => {
warehouseGridRef.current?.fitAllElementsToView();
}, 100);
}
const handleFileImport = async (file: File) => {
try {
// Create FormData properly
const formData = new FormData();
formData.append('file', file);
toast.loading(t(language, 'importing_file'));
// Use native fetch
const response = await fetch(${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3333'}/api/warehouses/import/dwg, {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!response.ok) {
throw new Error(Upload failed: ${response.status} ${response.statusText});
}
const data = await response.json();
toast.dismiss();
if (data && data.elements && Array.isArray(data.elements)) {
console.log('Received elements:', data.elements);
// Add the imported elements to the grid
setGridElements(prev => [...prev, ...data.elements]);
toast.success(t(language, 'file_imported_successfully'));
} else {
throw new Error('Invalid response from server');
}
} catch (error: any) {
console.error('Error importing file:', error);
toast.dismiss();
toast.error(error.message || t(language, 'error_importing_file'));
}
};
const findElementCategory = (searchTerm: string): ElementCategory => {
const searchTermLower = searchTerm.toLowerCase();
const elementCategories = {
wall: "building",
door: "building",
floor: "building",
rack: "storage",
shelf: "storage",
forklift: "vehicles",
} as const;
const foundElement = Object.entries(elementCategories).find(([element]) =>
element.includes(searchTermLower)
);
return foundElement ? (foundElement[1] as ElementCategory) : null;
};
const handleSearch = (query: string) => {
setSearchQuery(query);
const category = findElementCategory(query);
if (category) {
setActiveCategory(category);
}
};
const handleExitWarehouse = () => {
router.push("/");
};
const handleLogout = async () => {
try {
// Find the session cookie (it will be a long random string)
const sessionId = Object.keys(
document.cookie.split("; ").reduce((acc, cookie) => {
const [key, value] = cookie.split("=");
acc[key] = value;
return acc;
}, {} as Record<string, string>)
).find((key) => key.length > 20);
await api.post("/auth/logout", {}, { withCredentials: true });
// Clear any client-side storage
localStorage.clear();
// Clear the session cookie if found
if (sessionId) {
deleteCookie(sessionId, {
path: "/",
domain:
process.env.NODE_ENV === "production"
? ".intralogisticgrid"
: undefined,
maxAge: 0,
expires: new Date(0),
});
}
// Force a router refresh and redirect
router.refresh();
router.replace("/");
} catch (error) {
console.error("Logout failed", error);
toast.error(t(language, "logout_error"));
}
};
return (
<SidebarProvider>
<WarehouseDialog
open={showWarehouseDialog}
onOpenChange={setShowWarehouseDialog}
onFileImport={handleFileImport}
onNewWarehouse={handleNewWarehouse}
onLoadWarehouse={(data) => {
console.log("LoadWarehouseDialog onLoad called with data:", data);
if (data && Array.isArray(data)) {
console.log("Valid data, calling onLoadWarehouse");
handleLoadWarehouseData(data);
setShowLoadWarehouseDialog(false);
setShowWarehouseDialog(false);
} else {
console.error("Invalid data in WarehouseDialog onLoad:", data);
toast.error("Invalid warehouse data format");
}
}}
/>
<AppSidebar>
<WarehouseSidebar
selectedTool={selectedTool}
setSelectedTool={setSelectedTool}
activeCategory={activeCategory}
lang={language}
/>
</AppSidebar>
<SidebarInset className="flex flex-col h-screen">
<header className="fixed top-0 left-0 right-0 z-50 flex h-14 shrink-0 items-center justify-between bg-white border-b px-3">
<div className="flex items-center gap-2">
<ToolbarIcons onCategoryChange={setActiveCategory} />
</div>
<div className="flex items-center gap-4">
{/* View mode switch */}
<div className="flex items-center gap-2">
<Square className={h-4 w-4 ${!is3DMode ? 'text-primary' : 'text-muted-foreground'}} />
<Switch
checked={is3DMode}
onCheckedChange={setIs3DMode}
aria-label="Toggle 3D mode"
/>
<Cuboid className={h-4 w-4 ${is3DMode ? 'text-primary' : 'text-muted-foreground'}} />
</div>
{/* File operations */}
<Button
variant="ghost"
size="icon"
onClick={handleNewWarehouse}
title={t(language, "newWarehouse")}
>
<FilePlus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleLoadWarehouseClick}
title={t(language, "loadWarehouse")}
>
<FolderOpen className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSaveWarehouseDialog(true)}
title={t(language, "saveWarehouse")}
className={hasUnsavedChanges ? 'text-yellow-500' : ''}
>
<Save className="h-4 w-4" />
</Button>
{isSearching ? (
<div className="relative">
<Input
placeholder={t(language, "search")}
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
className="w-40 h-8"
autoFocus
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
onClick={() => {
setSearchQuery("");
setIsSearching(false);
}}
>
×
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon"
title={t(language, "searchElements")}
onClick={() => setIsSearching(true)}
>
<Search className="h-4 w-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title={t(language, "settings")}
>
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExitWarehouse}>
{t(language, "exitWarehouse")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>
{t(language, "logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="flex-1 overflow-hidden mt-14">
<WarehouseGrid
ref={warehouseGridRef}
selectedTool={selectedTool}
setSelectedTool={setSelectedTool}
lang={language}
is3DMode={is3DMode}
elements={gridElements}
onElementsChange={(newElements) => {
setGridElements(newElements);
setHasUnsavedChanges(true);
}}
/>
</div>
</SidebarInset>
{/* Unsaved changes dialog */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(language, "unsavedChangesTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t(language, "unsavedChangesDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowSaveDialog(false)}>
{t(language, "cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
handleSaveWarehouse();
setGridElements([]);
setShowSaveDialog(false);
setHasUnsavedChanges(false);
}}
>
{t(language, "save")}
</AlertDialogAction>
<AlertDialogAction
onClick={() => {
setGridElements([]);
setHasUnsavedChanges(false);
setShowSaveDialog(false);
}}
>
{t(language, "dontSave")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Save warehouse dialog */}
<SaveWarehouseDialog
open={showSaveWarehouseDialog}
onOpenChange={setShowSaveWarehouseDialog}
gridData={{
elements: gridElements || [], // Ensure we never pass null/undefined
scale: 1,
width: GRID_SIZE_METERS,
height: GRID_SIZE_METERS
}}
onSaved={() => setHasUnsavedChanges(false)}
/>
{/* Load warehouse dialog */}
<LoadWarehouseDialog
open={showLoadWarehouseDialog}
onOpenChange={setShowLoadWarehouseDialog}
onLoad={(data) => {
console.log("LoadWarehouseDialog onLoad called with data:", data);
if (!data || !Array.isArray(data)) {
toast.error(t(language, 'error_loading_warehouse'))
return
}
setGridElements(data)
setHasUnsavedChanges(false)
// Center the view on the loaded elements after a short delay
setTimeout(() => {
warehouseGridRef.current?.fitAllElementsToView();
}, 100);
}}
/>
{/* Unsaved changes dialog */}
<UnsavedChangesDialog
open={showUnsavedChangesDialog}
onAction={handleUnsavedChangesAction}
/>
{feUrn && (
<div style={{ margin: 20, height: 500 }}>
<FeViewer urn={feUrn} />
</div>
)}
</SidebarProvider>
);
}
I removed all imports. Thanks for any help