Tile windowing system
This commit is contained in:
parent
c6e6c5ca48
commit
43318fb8cd
35 changed files with 4659 additions and 360 deletions
281
app/components/windows/WindowManager.tsx
Normal file
281
app/components/windows/WindowManager.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect, createContext, useContext, memo } from "react";
|
||||
import { TileNode, LeafNode, ContainerNode, PanelId } from "./types";
|
||||
import {
|
||||
splitLeaf, closeLeaf, updatePanelId, patchSizes,
|
||||
getFirstLeafId, countLeaves, getLeafPanel,
|
||||
} from "./treeUtils";
|
||||
import { setFocusedContext, subscribeViewChange } from "@/stores/windowStore";
|
||||
import WindowPane from "./WindowPane";
|
||||
|
||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = "wm-layout-v3";
|
||||
const DEFAULT_TREE: TileNode = { type: "leaf", id: "root", panelId: "dashboard" };
|
||||
|
||||
function persist(t: TileNode) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(t)); } catch {}
|
||||
}
|
||||
|
||||
// ── Internal context ──────────────────────────────────────────────────────────
|
||||
|
||||
interface WMCtx {
|
||||
focusedId: string | null;
|
||||
totalPanes: number;
|
||||
isAuthed: boolean;
|
||||
onFocus: (paneId: string, panelId: PanelId) => void;
|
||||
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
|
||||
onClose: (leafId: string) => void;
|
||||
onResizeContainer: (containerId: string, sizes: number[]) => void;
|
||||
}
|
||||
|
||||
const WMContext = createContext<WMCtx>(null!);
|
||||
const useWM = () => useContext(WMContext);
|
||||
|
||||
// ── Resize handle ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface ResizeHandleProps {
|
||||
containerId: string;
|
||||
index: number;
|
||||
dir: "h" | "v";
|
||||
sizes: number[];
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
function ResizeHandle({ containerId, index, dir, sizes, containerRef }: ResizeHandleProps) {
|
||||
const { onResizeContainer } = useWM();
|
||||
const isCol = dir === "h";
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const totalPx = isCol ? rect.width : rect.height;
|
||||
const totalRatio = sizes.reduce((a, b) => a + b, 0);
|
||||
const startPos = isCol ? e.clientX : e.clientY;
|
||||
const startSizes = [...sizes];
|
||||
|
||||
const onMove = (mv: MouseEvent) => {
|
||||
const delta = (isCol ? mv.clientX : mv.clientY) - startPos;
|
||||
const deltaRatio = (delta / totalPx) * totalRatio;
|
||||
const minRatio = totalRatio * 0.08;
|
||||
const next = [...startSizes];
|
||||
next[index] = Math.max(minRatio, startSizes[index] + deltaRatio);
|
||||
next[index + 1] = Math.max(minRatio, startSizes[index + 1] - deltaRatio);
|
||||
if (next[index] < minRatio) {
|
||||
next[index] = minRatio;
|
||||
next[index + 1] = startSizes[index] + startSizes[index + 1] - minRatio;
|
||||
} else if (next[index + 1] < minRatio) {
|
||||
next[index + 1] = minRatio;
|
||||
next[index] = startSizes[index] + startSizes[index + 1] - minRatio;
|
||||
}
|
||||
onResizeContainer(containerId, next);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.body.style.cursor = isCol ? "col-resize" : "row-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}, [containerId, index, dir, sizes, containerRef, onResizeContainer, isCol]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"group/div shrink-0 flex items-center justify-center z-10",
|
||||
"hover:bg-blue/5 transition-colors",
|
||||
isCol ? "w-[6px] cursor-col-resize" : "h-[6px] cursor-row-resize",
|
||||
].join(" ")}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className={[
|
||||
"rounded-full bg-secondary/40 group-hover/div:bg-blue/50 transition-colors",
|
||||
isCol ? "w-[2px] h-8" : "h-[2px] w-8",
|
||||
].join(" ")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recursive tree renderer ───────────────────────────────────────────────────
|
||||
|
||||
const LeafCard = memo(function LeafCard({ leaf }: { leaf: LeafNode }) {
|
||||
const { focusedId, totalPanes, isAuthed, onFocus, onSplit, onClose } = useWM();
|
||||
const isFocused = leaf.id === focusedId;
|
||||
|
||||
return (
|
||||
<div className="p-[2px] flex flex-col w-full h-full min-w-0 min-h-0 box-border">
|
||||
<div
|
||||
className={[
|
||||
"flex-1 min-h-0 rounded-xl overflow-hidden border transition-colors duration-150",
|
||||
totalPanes > 1 && isFocused
|
||||
? "border-blue/60 shadow-[0_0_0_1px_rgba(66,140,226,0.2)]"
|
||||
: "border-secondary/60",
|
||||
].join(" ")}
|
||||
onClick={() => onFocus(leaf.id, leaf.panelId)}
|
||||
>
|
||||
<WindowPane
|
||||
node={leaf}
|
||||
isFocused={isFocused}
|
||||
canClose={totalPanes > 1}
|
||||
onSplit={onSplit}
|
||||
onClose={onClose}
|
||||
isAuthed={isAuthed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function RenderTree({ node }: { node: TileNode }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { focusedId } = useWM();
|
||||
|
||||
if (node.type === "leaf") return <LeafCard leaf={node as LeafNode} />;
|
||||
|
||||
const container = node as ContainerNode;
|
||||
const isCol = container.dir === "h";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex w-full h-full min-w-0 min-h-0"
|
||||
style={{ flexDirection: isCol ? "row" : "column" }}
|
||||
>
|
||||
{container.children.map((child, i) => (
|
||||
<React.Fragment key={child.id}>
|
||||
<div
|
||||
className="min-w-0 min-h-0 flex"
|
||||
style={{
|
||||
flex: container.sizes[i] ?? 1,
|
||||
flexDirection: isCol ? "row" : "column",
|
||||
}}
|
||||
>
|
||||
<RenderTree node={child} />
|
||||
</div>
|
||||
{i < container.children.length - 1 && (
|
||||
<ResizeHandle
|
||||
containerId={container.id}
|
||||
index={i}
|
||||
dir={container.dir}
|
||||
sizes={container.sizes}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Root ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WindowManager({ isAuthed }: { isAuthed: boolean }) {
|
||||
const [tree, setTree] = useState<TileNode>(DEFAULT_TREE);
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// Refs for stale-closure-safe callbacks
|
||||
const treeRef = useRef(tree);
|
||||
const focusedIdRef = useRef(focusedId);
|
||||
treeRef.current = tree;
|
||||
focusedIdRef.current = focusedId;
|
||||
|
||||
// Load persisted layout
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) setTree(JSON.parse(saved));
|
||||
} catch {}
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
// Sync focused context to store whenever tree or focus changes
|
||||
useEffect(() => {
|
||||
const id = focusedId ?? getFirstLeafId(tree);
|
||||
const panelId = getLeafPanel(tree, id);
|
||||
if (id && panelId) setFocusedContext(id, panelId);
|
||||
}, [tree, focusedId]);
|
||||
|
||||
// Listen for sidebar view-change requests
|
||||
useEffect(() => {
|
||||
const unsub = subscribeViewChange((panelId) => {
|
||||
const id = focusedIdRef.current ?? getFirstLeafId(treeRef.current);
|
||||
setTree((prev) => {
|
||||
const next = updatePanelId(prev, id, panelId);
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
// Update store immediately so sidebar highlight updates
|
||||
setFocusedContext(id, panelId);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const onFocus = useCallback((paneId: string, panelId: PanelId) => {
|
||||
setFocusedId(paneId);
|
||||
setFocusedContext(paneId, panelId);
|
||||
}, []);
|
||||
|
||||
const onSplit = useCallback((leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => {
|
||||
setTree((prev) => {
|
||||
const next = splitLeaf(prev, leafId, dir, newFirst, panelId);
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onClose = useCallback((leafId: string) => {
|
||||
setTree((prev) => {
|
||||
const next = closeLeaf(prev, leafId);
|
||||
if (!next) return prev;
|
||||
persist(next);
|
||||
// If closed pane was focused, move focus to first leaf
|
||||
if (focusedIdRef.current === leafId) {
|
||||
const firstId = getFirstLeafId(next);
|
||||
const panelId = getLeafPanel(next, firstId);
|
||||
setFocusedId(firstId);
|
||||
if (panelId) setFocusedContext(firstId, panelId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onResizeContainer = useCallback((containerId: string, sizes: number[]) => {
|
||||
setTree((prev) => {
|
||||
const next = patchSizes(prev, containerId, sizes);
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const total = countLeaves(tree);
|
||||
|
||||
const ctx: WMCtx = {
|
||||
focusedId: focusedId ?? (ready ? getFirstLeafId(tree) : null),
|
||||
totalPanes: total,
|
||||
isAuthed,
|
||||
onFocus,
|
||||
onSplit,
|
||||
onClose,
|
||||
onResizeContainer,
|
||||
};
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<WMContext.Provider value={ctx}>
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<RenderTree node={tree} />
|
||||
</div>
|
||||
</WMContext.Provider>
|
||||
);
|
||||
}
|
||||
296
app/components/windows/WindowPane.tsx
Normal file
296
app/components/windows/WindowPane.tsx
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect, lazy, Suspense } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { LeafNode, PanelId, PANEL_LABELS, PANEL_SECTIONS } from "./types";
|
||||
import {
|
||||
IconX, IconLayoutColumns, IconLayoutRows, IconRefresh,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const DashboardPanel = lazy(() => import("../panels/DashboardPanel"));
|
||||
const AnalyticsPanel = lazy(() => import("../panels/AnalyticsPanel"));
|
||||
|
||||
function PanelContent({ panelId, isAuthed }: { panelId: PanelId; isAuthed: boolean }) {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-4"><div className="skeleton h-32 rounded-2xl" /></div>}>
|
||||
{panelId === "dashboard" && <DashboardPanel isAuthed={isAuthed} />}
|
||||
{panelId === "analytics-line" && <AnalyticsPanel chartType="line" />}
|
||||
{panelId === "analytics-bar" && <AnalyticsPanel chartType="bar" />}
|
||||
{panelId === "analytics-candle" && <AnalyticsPanel chartType="candle" />}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// ── View picker portal ────────────────────────────────────────────────────────
|
||||
|
||||
type SplitDir = "right" | "down";
|
||||
|
||||
function ViewPickerPortal({
|
||||
anchorRef,
|
||||
mode,
|
||||
currentView,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||
mode: "change" | SplitDir;
|
||||
currentView: PanelId;
|
||||
onPick: (panelId: PanelId) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const rect = anchorRef.current?.getBoundingClientRect();
|
||||
if (rect) setPos({ top: rect.bottom + 6, left: rect.left });
|
||||
}, []);
|
||||
|
||||
// Clamp to viewport after mount
|
||||
useEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const r = menuRef.current.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
let { left, top } = pos;
|
||||
if (r.right > window.innerWidth - pad) left = window.innerWidth - r.width - pad;
|
||||
if (r.left < pad) left = pad;
|
||||
if (r.bottom > window.innerHeight - pad) top = window.innerHeight - r.height - pad;
|
||||
if (left !== pos.left || top !== pos.top) setPos({ top, left });
|
||||
}, [pos]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(e.target as Node) &&
|
||||
!anchorRef.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const title = mode === "change" ? "Change View" : mode === "right" ? "Tile Right" : "Tile Down";
|
||||
|
||||
// Dashboard entry + sections
|
||||
const dashboardActive = currentView === "dashboard";
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 9999 }}
|
||||
className="w-[220px] max-h-[420px] overflow-y-auto bg-primary border border-secondary rounded-xl shadow-2xl py-1"
|
||||
>
|
||||
<p className="px-3 pt-1.5 pb-1 text-[10px] font-semibold text-foreground-sec uppercase tracking-wider">
|
||||
{title}
|
||||
</p>
|
||||
{/* Dashboard standalone */}
|
||||
<button
|
||||
onClick={() => { onPick("dashboard"); onClose(); }}
|
||||
className={[
|
||||
"w-[calc(100%-8px)] mx-[4px] flex items-center gap-2.5 px-2.5 py-[5px] rounded-lg text-left text-xs transition-colors cursor-pointer",
|
||||
dashboardActive
|
||||
? "bg-blue/12 border border-blue/30 text-blue font-medium"
|
||||
: "text-foreground hover:bg-secondary/60 border border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className={[
|
||||
"w-[28px] h-[28px] shrink-0 flex items-center justify-center rounded-[7px] border text-[10px] font-bold",
|
||||
dashboardActive
|
||||
? "bg-blue/15 border-blue/35 text-blue"
|
||||
: "bg-secondary/50 border-secondary text-foreground-sec",
|
||||
].join(" ")}>D</span>
|
||||
Dashboard
|
||||
</button>
|
||||
{/* Power Analytics section */}
|
||||
{PANEL_SECTIONS.map((section, si) => (
|
||||
<div key={section.id}>
|
||||
<div className="mx-2 my-1 h-px bg-secondary" />
|
||||
<p className="px-3 pt-1 pb-0.5 text-[9px] font-semibold text-foreground-sec/60 uppercase tracking-wider">
|
||||
{section.label}
|
||||
</p>
|
||||
{section.items.map(({ panelId, label }) => {
|
||||
const active = panelId === currentView;
|
||||
return (
|
||||
<button
|
||||
key={panelId}
|
||||
onClick={() => { onPick(panelId); onClose(); }}
|
||||
className={[
|
||||
"w-[calc(100%-8px)] mx-[4px] flex items-center gap-2.5 px-2.5 py-[5px] rounded-lg text-left text-xs transition-colors cursor-pointer",
|
||||
active
|
||||
? "bg-blue/12 border border-blue/30 text-blue font-medium"
|
||||
: "text-foreground hover:bg-secondary/60 border border-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className={[
|
||||
"w-[28px] h-[28px] shrink-0 flex items-center justify-center rounded-[7px] border text-[10px] font-bold",
|
||||
active
|
||||
? "bg-blue/15 border-blue/35 text-blue"
|
||||
: "bg-secondary/50 border-secondary text-foreground-sec",
|
||||
].join(" ")}>
|
||||
{label.charAt(0)}
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Window controls pill ──────────────────────────────────────────────────────
|
||||
|
||||
type MenuMode = "change" | SplitDir;
|
||||
|
||||
function WindowControls({
|
||||
paneId,
|
||||
currentView,
|
||||
canClose,
|
||||
onClose,
|
||||
onSplit,
|
||||
onChangeView,
|
||||
}: {
|
||||
paneId: string;
|
||||
currentView: PanelId;
|
||||
canClose: boolean;
|
||||
onClose: (id: string) => void;
|
||||
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
|
||||
onChangeView: (panelId: PanelId) => void;
|
||||
}) {
|
||||
const [menu, setMenu] = useState<MenuMode | null>(null);
|
||||
const [pillHovered, setPillHovered] = useState(false);
|
||||
|
||||
const changeRef = useRef<HTMLButtonElement>(null);
|
||||
const rightRef = useRef<HTMLButtonElement>(null);
|
||||
const downRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handlePick = (panelId: PanelId) => {
|
||||
if (menu === "change") onChangeView(panelId);
|
||||
else if (menu === "right") onSplit(paneId, "h", false, panelId);
|
||||
else if (menu === "down") onSplit(paneId, "v", false, panelId);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
const anchorRef = menu === "change" ? changeRef : menu === "right" ? rightRef : downRef;
|
||||
|
||||
const BTN = "w-[26px] h-[26px] flex items-center justify-center rounded-full transition-colors cursor-pointer text-foreground-sec hover:text-foreground";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-0.5 px-1 py-0.5 rounded-full border transition-colors duration-150"
|
||||
style={{
|
||||
borderColor: pillHovered ? "rgba(66,140,226,0.35)" : "var(--color-secondary)",
|
||||
background: pillHovered ? "color-mix(in srgb, var(--color-blue) 8%, var(--color-primary))" : "var(--color-primary)",
|
||||
boxShadow: pillHovered ? "none" : "0 1px 4px rgba(0,0,0,0.18)",
|
||||
transition: "background 150ms, border-color 150ms, box-shadow 150ms",
|
||||
}}
|
||||
onMouseEnter={() => setPillHovered(true)}
|
||||
onMouseLeave={() => setPillHovered(false)}
|
||||
>
|
||||
{canClose && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClose(paneId); }}
|
||||
className={`${BTN} hover:text-red-400 hover:bg-red-500/10`}
|
||||
title="Close"
|
||||
>
|
||||
<IconX size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={changeRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "change" ? null : "change"); }}
|
||||
className={`${BTN} ${menu === "change" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Change view"
|
||||
>
|
||||
<IconRefresh size={13} />
|
||||
</button>
|
||||
<button
|
||||
ref={rightRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "right" ? null : "right"); }}
|
||||
className={`${BTN} ${menu === "right" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Tile right"
|
||||
>
|
||||
<IconLayoutColumns size={13} />
|
||||
</button>
|
||||
<button
|
||||
ref={downRef}
|
||||
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "down" ? null : "down"); }}
|
||||
className={`${BTN} ${menu === "down" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
|
||||
title="Tile down"
|
||||
>
|
||||
<IconLayoutRows size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{menu && (
|
||||
<ViewPickerPortal
|
||||
anchorRef={anchorRef}
|
||||
mode={menu}
|
||||
currentView={currentView}
|
||||
onPick={handlePick}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── WindowPane ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WindowPane({
|
||||
node,
|
||||
isFocused,
|
||||
canClose,
|
||||
onSplit,
|
||||
onClose,
|
||||
isAuthed,
|
||||
}: {
|
||||
node: LeafNode;
|
||||
isFocused: boolean;
|
||||
canClose: boolean;
|
||||
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
|
||||
onClose: (leafId: string) => void;
|
||||
isAuthed: boolean;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const handleChangeView = (panelId: PanelId) => {
|
||||
import("@/stores/windowStore").then(({ requestViewChange }) => requestViewChange(panelId));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full flex flex-col bg-primary"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div className="shrink-0 h-[28px] flex items-center justify-between px-3 border-b border-secondary/40">
|
||||
<span className="text-[11px] font-medium text-foreground-sec truncate select-none">
|
||||
{PANEL_LABELS[node.panelId]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<PanelContent panelId={node.panelId} isAuthed={isAuthed} />
|
||||
</div>
|
||||
|
||||
{/* Floating controls pill */}
|
||||
<div
|
||||
className="absolute top-[4px] right-[8px] z-20 transition-opacity duration-150 pointer-events-none"
|
||||
style={{ opacity: hovered ? 1 : 0, pointerEvents: hovered ? "auto" : "none" }}
|
||||
>
|
||||
<WindowControls
|
||||
paneId={node.id}
|
||||
currentView={node.panelId}
|
||||
canClose={canClose}
|
||||
onClose={onClose}
|
||||
onSplit={onSplit}
|
||||
onChangeView={handleChangeView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
app/components/windows/treeUtils.ts
Normal file
75
app/components/windows/treeUtils.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { TileNode, LeafNode, ContainerNode, PanelId } from "./types";
|
||||
|
||||
let _id = 0;
|
||||
export function gid(): string {
|
||||
return `wn-${Date.now()}-${++_id}`;
|
||||
}
|
||||
|
||||
// ── Leaf operations ───────────────────────────────────────────────────────────
|
||||
|
||||
function mapLeaf(tree: TileNode, leafId: string, fn: (leaf: LeafNode) => TileNode): TileNode {
|
||||
if (tree.type === "leaf") return tree.id === leafId ? fn(tree) : tree;
|
||||
return { ...tree, children: tree.children.map((c) => mapLeaf(c, leafId, fn)) };
|
||||
}
|
||||
|
||||
export function splitLeaf(
|
||||
tree: TileNode,
|
||||
leafId: string,
|
||||
dir: "h" | "v",
|
||||
newFirst: boolean,
|
||||
newPanelId: PanelId,
|
||||
): TileNode {
|
||||
const newLeaf: LeafNode = { type: "leaf", id: gid(), panelId: newPanelId };
|
||||
return mapLeaf(tree, leafId, (leaf) => ({
|
||||
type: "container",
|
||||
id: gid(),
|
||||
dir,
|
||||
children: newFirst ? [newLeaf, leaf] : [leaf, newLeaf],
|
||||
sizes: [1, 1],
|
||||
}));
|
||||
}
|
||||
|
||||
export function closeLeaf(tree: TileNode, leafId: string): TileNode | null {
|
||||
if (tree.type === "leaf") return tree.id === leafId ? null : tree;
|
||||
const children: TileNode[] = [];
|
||||
const sizes: number[] = [];
|
||||
tree.children.forEach((c, i) => {
|
||||
const r = closeLeaf(c, leafId);
|
||||
if (r !== null) { children.push(r); sizes.push(tree.sizes[i]); }
|
||||
});
|
||||
if (children.length === 0) return null;
|
||||
if (children.length === 1) return children[0]; // collapse single-child container
|
||||
return { ...tree, children, sizes };
|
||||
}
|
||||
|
||||
export function updatePanelId(tree: TileNode, leafId: string, panelId: PanelId): TileNode {
|
||||
if (tree.type === "leaf") return tree.id === leafId ? { ...tree, panelId } : tree;
|
||||
return { ...tree, children: tree.children.map((c) => updatePanelId(c, leafId, panelId)) };
|
||||
}
|
||||
|
||||
export function patchSizes(tree: TileNode, containerId: string, sizes: number[]): TileNode {
|
||||
if (tree.type === "leaf") return tree;
|
||||
if (tree.id === containerId) return { ...tree, sizes };
|
||||
return { ...tree, children: tree.children.map((c) => patchSizes(c, containerId, sizes)) };
|
||||
}
|
||||
|
||||
// ── Query helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function getFirstLeafId(tree: TileNode): string {
|
||||
if (tree.type === "leaf") return tree.id;
|
||||
return getFirstLeafId(tree.children[0]);
|
||||
}
|
||||
|
||||
export function countLeaves(tree: TileNode): number {
|
||||
if (tree.type === "leaf") return 1;
|
||||
return tree.children.reduce((s, c) => s + countLeaves(c), 0);
|
||||
}
|
||||
|
||||
export function getLeafPanel(tree: TileNode, leafId: string): PanelId | null {
|
||||
if (tree.type === "leaf") return tree.id === leafId ? tree.panelId : null;
|
||||
for (const c of tree.children) {
|
||||
const p = getLeafPanel(c, leafId);
|
||||
if (p) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
43
app/components/windows/types.ts
Normal file
43
app/components/windows/types.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export type PanelId =
|
||||
| "dashboard"
|
||||
| "analytics-line"
|
||||
| "analytics-bar"
|
||||
| "analytics-candle";
|
||||
|
||||
export const PANEL_LABELS: Record<PanelId, string> = {
|
||||
dashboard: "Dashboard",
|
||||
"analytics-line": "Power Analytics — Line",
|
||||
"analytics-bar": "Power Analytics — Bar",
|
||||
"analytics-candle": "Power Analytics — Candlestick",
|
||||
};
|
||||
|
||||
// ── Sidebar sections ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PanelEntry { panelId: PanelId; label: string; }
|
||||
export interface PanelSection { id: string; label: string; items: PanelEntry[]; }
|
||||
|
||||
export const PANEL_SECTIONS: PanelSection[] = [
|
||||
{
|
||||
id: "power-analytics",
|
||||
label: "Power Analytics",
|
||||
items: [
|
||||
{ panelId: "analytics-line", label: "Line Chart" },
|
||||
{ panelId: "analytics-bar", label: "Bar Chart" },
|
||||
{ panelId: "analytics-candle", label: "Candlestick" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_PANELS: PanelId[] = Object.keys(PANEL_LABELS) as PanelId[];
|
||||
|
||||
// ── N-ary pane tree ───────────────────────────────────────────────────────────
|
||||
|
||||
export type LeafNode = { type: "leaf"; id: string; panelId: PanelId };
|
||||
export type ContainerNode = {
|
||||
type: "container";
|
||||
id: string;
|
||||
dir: "h" | "v";
|
||||
children: TileNode[];
|
||||
sizes: number[];
|
||||
};
|
||||
export type TileNode = LeafNode | ContainerNode;
|
||||
Loading…
Add table
Add a link
Reference in a new issue