feat: add window component with traffic lights and framer-motion
This commit is contained in:
86
src/components/Window/Window.css
Normal file
86
src/components/Window/Window.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
.window {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: var(--glass-blur);
|
||||||
|
-webkit-backdrop-filter: var(--glass-blur);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-window);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window--unfocused {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar {
|
||||||
|
height: var(--titlebar-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: move;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-lights {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: filter 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window--unfocused .traffic-light {
|
||||||
|
background: #666 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light--close { background: var(--accent-red); }
|
||||||
|
.traffic-light--minimize { background: var(--accent-yellow); }
|
||||||
|
.traffic-light--maximize { background: var(--accent-green); }
|
||||||
|
|
||||||
|
.traffic-lights:hover .traffic-light--close::after { content: '✕'; }
|
||||||
|
.traffic-lights:hover .traffic-light--minimize::after { content: '−'; }
|
||||||
|
.traffic-lights:hover .traffic-light--maximize::after { content: '+'; }
|
||||||
|
|
||||||
|
.traffic-light::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(0,0,0,0.6);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
75
src/components/Window/Window.tsx
Normal file
75
src/components/Window/Window.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Rnd } from 'react-rnd';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useOS } from '../../context/WindowContext';
|
||||||
|
import type { AppConfig } from '../../types';
|
||||||
|
import './Window.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
app: AppConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Window({ app }: Props) {
|
||||||
|
const { state, closeWindow, minimizeWindow, maximizeWindow, focusWindow, moveWindow, resizeWindow } = useOS();
|
||||||
|
const win = state.windows[app.id];
|
||||||
|
const isFocused = win.zIndex === state.topZ;
|
||||||
|
const isVisible = win.isOpen && !win.isMinimized;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.div
|
||||||
|
key={app.id}
|
||||||
|
style={{ position: 'fixed', zIndex: win.zIndex, top: 0, left: 0, width: 0, height: 0 }}
|
||||||
|
initial={{ opacity: 0, scale: 0.92 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.92 }}
|
||||||
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||||
|
>
|
||||||
|
<Rnd
|
||||||
|
position={win.position}
|
||||||
|
size={win.size}
|
||||||
|
onDragStop={(_, d) => moveWindow(app.id, d.x, d.y)}
|
||||||
|
onResizeStop={(_, __, ref, ___, pos) => {
|
||||||
|
resizeWindow(app.id, ref.offsetWidth, ref.offsetHeight);
|
||||||
|
moveWindow(app.id, pos.x, pos.y);
|
||||||
|
}}
|
||||||
|
dragHandleClassName="window-titlebar"
|
||||||
|
minWidth={280}
|
||||||
|
minHeight={200}
|
||||||
|
bounds="window"
|
||||||
|
onMouseDown={() => focusWindow(app.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`window ${isFocused ? 'window--focused' : 'window--unfocused'}`}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<div className="window-titlebar">
|
||||||
|
<div className="traffic-lights">
|
||||||
|
<button
|
||||||
|
className="traffic-light traffic-light--close"
|
||||||
|
onClick={() => closeWindow(app.id)}
|
||||||
|
title="Close"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="traffic-light traffic-light--minimize"
|
||||||
|
onClick={() => minimizeWindow(app.id)}
|
||||||
|
title="Minimize"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="traffic-light traffic-light--maximize"
|
||||||
|
onClick={() => maximizeWindow(app.id, window.innerWidth, window.innerHeight)}
|
||||||
|
title="Maximize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="window-title">{app.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="window-content">
|
||||||
|
<app.component />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Rnd>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user