Spaces:
Sleeping
Sleeping
| import React, { useState, useRef, useEffect } from 'react'; | |
| interface WindowShellProps { | |
| id: string; | |
| title: string; | |
| isOpen: boolean; | |
| isMinimized: boolean; | |
| onClose: () => void; | |
| onMinimize: () => void; | |
| activeWindow: string; | |
| setActiveWindow: (id: string) => void; | |
| children: React.ReactNode; | |
| icon?: string; | |
| defaultWidth?: string; | |
| defaultHeight?: string; | |
| defaultX?: number; | |
| defaultY?: number; | |
| titleBarColor?: string; | |
| isMobile?: boolean; | |
| } | |
| export default function WindowShell({ | |
| id, | |
| title, | |
| isOpen, | |
| isMinimized, | |
| onClose, | |
| onMinimize, | |
| activeWindow, | |
| setActiveWindow, | |
| children, | |
| icon = 'terminal', | |
| defaultWidth = '700px', | |
| defaultHeight = '600px', | |
| defaultX, | |
| defaultY, | |
| titleBarColor = '#0ea5e9', | |
| isMobile = false, | |
| }: WindowShellProps) { | |
| // Helper to parse default dimensions (e.g. '700px') into integer values | |
| const parseDimension = (val: string): number => { | |
| const num = parseInt(val, 10); | |
| return isNaN(num) ? 500 : num; | |
| }; | |
| const [position, setPosition] = useState({ x: defaultX ?? 0, y: defaultY ?? 0 }); | |
| const [dimensions, setDimensions] = useState({ | |
| width: parseDimension(defaultWidth), | |
| height: parseDimension(defaultHeight) | |
| }); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [isResizing, setIsResizing] = useState(false); | |
| const dragStart = useRef({ x: 0, y: 0 }); | |
| const resizeStart = useRef({ width: 0, height: 0, x: 0, y: 0 }); | |
| const windowRef = useRef<HTMLDivElement>(null); | |
| const isActive = activeWindow === id; | |
| useEffect(() => { | |
| // If not set, randomize offset slightly to feel "tossed on the desk" | |
| if (defaultX === undefined && defaultY === undefined) { | |
| const randomX = Math.floor(Math.random() * 40) - 20; | |
| const randomY = Math.floor(Math.random() * 40) - 20; | |
| setPosition({ x: randomX, y: randomY }); | |
| } | |
| }, [defaultX, defaultY]); | |
| const handleMouseDown = (e: React.MouseEvent) => { | |
| if (isMobile) return; | |
| // Only drag on left click and not on control buttons or resize handles | |
| if (e.button !== 0) return; | |
| const target = e.target as HTMLElement; | |
| if (target.closest('.window-control-btn') || target.closest('.window-resize-handle')) return; | |
| setActiveWindow(id); | |
| setIsDragging(true); | |
| dragStart.current = { | |
| x: e.clientX - position.x, | |
| y: e.clientY - position.y | |
| }; | |
| e.preventDefault(); | |
| }; | |
| const handleResizeMouseDown = (e: React.MouseEvent) => { | |
| if (isMobile) return; | |
| if (e.button !== 0) return; | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| setActiveWindow(id); | |
| setIsResizing(true); | |
| resizeStart.current = { | |
| width: dimensions.width, | |
| height: dimensions.height, | |
| x: e.clientX, | |
| y: e.clientY | |
| }; | |
| }; | |
| useEffect(() => { | |
| const handleMouseMove = (e: MouseEvent) => { | |
| if (isDragging) { | |
| const newX = e.clientX - dragStart.current.x; | |
| const newY = e.clientY - dragStart.current.y; | |
| setPosition({ x: newX, y: newY }); | |
| } else if (isResizing) { | |
| const deltaX = e.clientX - resizeStart.current.x; | |
| const deltaY = e.clientY - resizeStart.current.y; | |
| // Enforce minimum dimensions (350x250 px) | |
| const newWidth = Math.max(350, resizeStart.current.width + deltaX); | |
| const newHeight = Math.max(250, resizeStart.current.height + deltaY); | |
| setDimensions({ | |
| width: newWidth, | |
| height: newHeight | |
| }); | |
| } | |
| }; | |
| const handleMouseUp = () => { | |
| setIsDragging(false); | |
| setIsResizing(false); | |
| }; | |
| if (isDragging || isResizing) { | |
| document.addEventListener('mousemove', handleMouseMove); | |
| document.addEventListener('mouseup', handleMouseUp); | |
| } | |
| return () => { | |
| document.removeEventListener('mousemove', handleMouseMove); | |
| document.removeEventListener('mouseup', handleMouseUp); | |
| }; | |
| }, [isDragging, isResizing, isMobile]); | |
| if (!isOpen || isMinimized) return null; | |
| return ( | |
| <div | |
| ref={windowRef} | |
| onMouseDown={() => setActiveWindow(id)} | |
| style={isMobile ? { | |
| zIndex: isActive ? 40 : 30, | |
| } : { | |
| width: `${dimensions.width}px`, | |
| height: `${dimensions.height}px`, | |
| transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px))`, | |
| zIndex: isActive ? 40 : 30, | |
| }} | |
| className={`absolute bg-paper-white border-[4px] flex flex-col overflow-hidden window-shadow transition-all ${ | |
| isMobile | |
| ? 'top-0 left-0 w-full h-[calc(100vh-56px)] max-w-none max-h-none border-t-0 border-x-0' | |
| : 'top-1/2 left-1/2 max-w-[98vw] max-h-[90vh] border-ink-black' | |
| } ${ | |
| isActive ? 'border-ink-black shadow-[8px_8px_0px_0px_rgba(30,41,59,1)]' : 'border-ink-black opacity-95 shadow-[4px_4px_0px_0px_rgba(30,41,59,1)]' | |
| }`} | |
| > | |
| {/* Title Bar */} | |
| <div | |
| onMouseDown={handleMouseDown} | |
| style={{ backgroundColor: titleBarColor }} | |
| className="border-b-[4px] border-ink-black px-4 py-2 flex justify-between items-center text-white shrink-0 cursor-move select-none" | |
| > | |
| <div className="flex items-center gap-2"> | |
| {icon && ( | |
| <span className="material-symbols-outlined select-none" style={{ fontVariationSettings: "'FILL' 1" }}> | |
| {icon} | |
| </span> | |
| )} | |
| <span className="font-window-title text-sm md:text-base font-bold tracking-wide select-none">{title}</span> | |
| </div> | |
| <div className="flex gap-1.5"> | |
| {!isMobile && ( | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onMinimize(); | |
| }} | |
| className="window-control-btn w-7 h-7 bg-white text-ink-black border-[3px] border-ink-black flex items-center justify-center hover:bg-yellow-300 active:translate-y-0.5 active:translate-x-0.5 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:shadow-none transition-all cursor-pointer" | |
| > | |
| <span className="material-symbols-outlined text-[14px] font-bold">minimize</span> | |
| </button> | |
| )} | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onClose(); | |
| }} | |
| className="window-control-btn w-7 h-7 bg-highlight-pink text-white border-[3px] border-ink-black flex items-center justify-center hover:bg-rose-600 active:translate-y-0.5 active:translate-x-0.5 shadow-[2px_2px_0px_0px_rgba(30,41,59,1)] active:shadow-none transition-all cursor-pointer" | |
| > | |
| <span className="material-symbols-outlined text-[14px] font-bold">close</span> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Window Content */} | |
| <div className="flex-1 overflow-hidden relative bg-paper-white flex flex-col"> | |
| {children} | |
| </div> | |
| {/* Resize Handle */} | |
| {!isMobile && ( | |
| <div | |
| onMouseDown={handleResizeMouseDown} | |
| className="window-resize-handle absolute bottom-0 right-0 w-4 h-4 cursor-se-resize flex items-end justify-end p-0.5 select-none z-[50]" | |
| > | |
| <svg width="10" height="10" viewBox="0 0 10 10" className="text-ink-black opacity-60"> | |
| <line x1="8" y1="2" x2="2" y2="8" stroke="currentColor" strokeWidth="1.5" /> | |
| <line x1="8" y1="5" x2="5" y2="8" stroke="currentColor" strokeWidth="1.5" /> | |
| </svg> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |