iamfebin's picture
feat: implement Metro-style Windows Phone mobile responsive UI
080f0c0
Raw
History Blame Contribute Delete
7.45 kB
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>
);
}