UI overhaul: unified sidebar layout with app-scoped views
Replace disconnected Transactions/Applications pages with a persistent collapsible sidebar listing apps by health status. Add app-scoped view (/apps/:group) with filtered stats, route chips, and scoped table. Merge Processor Tree into diagram detail panel with Inspector/Tree toggle and resizable divider. Remove max-width constraint for full viewport usage. All view states are deep-linkable via URL. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
.main {
|
||||
.layout {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { TopNav } from './TopNav';
|
||||
import { AppSidebar } from './AppSidebar';
|
||||
import { CommandPalette } from '../command-palette/CommandPalette';
|
||||
import styles from './AppShell.module.css';
|
||||
|
||||
const COLLAPSED_KEY = 'cameleer-sidebar-collapsed';
|
||||
|
||||
export function AppShell() {
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem(COLLAPSED_KEY) === 'true'; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
// Auto-collapse on small screens
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1024px)');
|
||||
function handleChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (e.matches) setCollapsed(true);
|
||||
}
|
||||
handleChange(mq);
|
||||
mq.addEventListener('change', handleChange);
|
||||
return () => mq.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
function toggleSidebar() {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try { localStorage.setItem(COLLAPSED_KEY, String(next)); }
|
||||
catch { /* ignore */ }
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopNav />
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
<TopNav onToggleSidebar={toggleSidebar} />
|
||||
<div className={styles.layout}>
|
||||
<AppSidebar collapsed={collapsed} />
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<CommandPalette />
|
||||
</>
|
||||
);
|
||||
|
||||
245
ui/src/components/layout/AppSidebar.module.css
Normal file
245
ui/src/components/layout/AppSidebar.module.css
Normal file
@@ -0,0 +1,245 @@
|
||||
/* ─── Sidebar Container ─── */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebarCollapsed {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
/* ─── Search ─── */
|
||||
.search {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
/* ─── App List ─── */
|
||||
.appList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ─── Section Divider ─── */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 4px 12px;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .divider {
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ─── App Item ─── */
|
||||
.appItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appItem:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.appItemActive {
|
||||
background: var(--amber-glow);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .appItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ─── Health Dot ─── */
|
||||
.healthDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotLive { background: var(--green); }
|
||||
.dotStale { background: var(--amber); }
|
||||
.dotDead { background: var(--text-muted); }
|
||||
|
||||
/* ─── App Info (hidden when collapsed) ─── */
|
||||
.appInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .appInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.appMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── All Item icon ─── */
|
||||
.allIcon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.appItemActive .allIcon {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ─── Bottom Section ─── */
|
||||
.bottom {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.bottomItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottomItem:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bottomItemActive {
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .bottomItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bottomLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .bottomLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bottomIcon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.sidebar .search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar .appInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar .appItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar .divider {
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.sidebar .bottomItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar .bottomLabel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
129
ui/src/components/layout/AppSidebar.tsx
Normal file
129
ui/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { NavLink, useParams } from 'react-router';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
import styles from './AppSidebar.module.css';
|
||||
|
||||
interface GroupInfo {
|
||||
group: string;
|
||||
agents: AgentInstance[];
|
||||
liveCount: number;
|
||||
staleCount: number;
|
||||
deadCount: number;
|
||||
}
|
||||
|
||||
function healthStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
|
||||
if (g.liveCount > 0) return 'live';
|
||||
if (g.staleCount > 0) return 'stale';
|
||||
return 'dead';
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||
const { group: activeGroup } = useParams<{ group: string }>();
|
||||
const { data: agents } = useAgents();
|
||||
const { roles } = useAuthStore();
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!agents) return [];
|
||||
const map = new Map<string, GroupInfo>();
|
||||
for (const agent of agents) {
|
||||
const key = agent.group ?? 'default';
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { group: key, agents: [], liveCount: 0, staleCount: 0, deadCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.agents.push(agent);
|
||||
if (agent.status === 'LIVE') entry.liveCount++;
|
||||
else if (agent.status === 'STALE') entry.staleCount++;
|
||||
else entry.deadCount++;
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
|
||||
}, [agents]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return groups;
|
||||
const lower = filter.toLowerCase();
|
||||
return groups.filter((g) => g.group.toLowerCase().includes(lower));
|
||||
}, [groups, filter]);
|
||||
|
||||
const sidebarClass = `${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`;
|
||||
|
||||
return (
|
||||
<aside className={sidebarClass}>
|
||||
{/* Search */}
|
||||
<div className={styles.search}>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter apps..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App List */}
|
||||
<div className={styles.appList}>
|
||||
{/* All (unscoped) */}
|
||||
<NavLink
|
||||
to="/executions"
|
||||
className={({ isActive }) =>
|
||||
`${styles.appItem} ${isActive && !activeGroup ? styles.appItemActive : ''}`
|
||||
}
|
||||
title="All Applications"
|
||||
>
|
||||
<span className={styles.allIcon}>*</span>
|
||||
<span className={styles.appInfo}>
|
||||
<span className={styles.appName}>All</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
{/* App entries */}
|
||||
{filtered.map((g) => {
|
||||
const status = healthStatus(g);
|
||||
const isActive = activeGroup === g.group;
|
||||
return (
|
||||
<NavLink
|
||||
key={g.group}
|
||||
to={`/apps/${encodeURIComponent(g.group)}`}
|
||||
className={`${styles.appItem} ${isActive ? styles.appItemActive : ''}`}
|
||||
title={g.group}
|
||||
>
|
||||
<span className={`${styles.healthDot} ${styles[`dot${status.charAt(0).toUpperCase()}${status.slice(1)}`]}`} />
|
||||
<span className={styles.appInfo}>
|
||||
<span className={styles.appName}>{g.group}</span>
|
||||
<span className={styles.appMeta}>
|
||||
{g.agents.length} agent{g.agents.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom: Admin */}
|
||||
{roles.includes('ADMIN') && (
|
||||
<div className={styles.bottom}>
|
||||
<NavLink
|
||||
to="/admin/oidc"
|
||||
className={({ isActive }) =>
|
||||
`${styles.bottomItem} ${isActive ? styles.bottomItemActive : ''}`
|
||||
}
|
||||
title="Admin"
|
||||
>
|
||||
<span className={styles.bottomIcon}>⚙</span>
|
||||
<span className={styles.bottomLabel}>Admin</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,32 @@
|
||||
background: var(--topnav-bg);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0 24px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: 32px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hamburger:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -27,38 +48,57 @@
|
||||
|
||||
.logo:hover { color: var(--amber); }
|
||||
|
||||
.navLinks {
|
||||
/* ─── Search Bar ─── */
|
||||
.searchBar {
|
||||
flex: 1;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 8px 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
composes: navLink;
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
.searchBar:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchPlaceholder {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchKbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Right Section ─── */
|
||||
.navRight {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.utilLink {
|
||||
@@ -136,3 +176,10 @@
|
||||
.logoutBtn:hover {
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 768px) {
|
||||
.searchBar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { NavLink } from 'react-router';
|
||||
import { useThemeStore } from '../../theme/theme-store';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useCommandPalette } from '../command-palette/use-command-palette';
|
||||
import styles from './TopNav.module.css';
|
||||
|
||||
export function TopNav() {
|
||||
interface TopNavProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export function TopNav({ onToggleSidebar }: TopNavProps) {
|
||||
const { theme, toggle } = useThemeStore();
|
||||
const { username, roles, logout } = useAuthStore();
|
||||
const { username, logout } = useAuthStore();
|
||||
const openPalette = useCommandPalette((s) => s.open);
|
||||
|
||||
return (
|
||||
<nav className={styles.topnav}>
|
||||
<button className={styles.hamburger} onClick={onToggleSidebar} title="Toggle sidebar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<NavLink to="/" className={styles.logo}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
||||
@@ -17,25 +29,15 @@ export function TopNav() {
|
||||
cameleer3
|
||||
</NavLink>
|
||||
|
||||
<ul className={styles.navLinks}>
|
||||
<li>
|
||||
<NavLink to="/executions" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Transactions
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink to="/apps" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Applications
|
||||
</NavLink>
|
||||
</li>
|
||||
{roles.includes('ADMIN') && (
|
||||
<li>
|
||||
<NavLink to="/admin/oidc" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
|
||||
Admin
|
||||
</NavLink>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* Visible search bar */}
|
||||
<div className={styles.searchBar} onClick={openPalette} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openPalette(); }}>
|
||||
<svg className={styles.searchIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<span className={styles.searchPlaceholder}>Search executions, orders...</span>
|
||||
<kbd className={styles.searchKbd}>⌘K</kbd>
|
||||
</div>
|
||||
|
||||
<div className={styles.navRight}>
|
||||
<NavLink to="/swagger" className={({ isActive }) => isActive ? styles.utilLinkActive : styles.utilLink} title="API Documentation">
|
||||
|
||||
73
ui/src/components/shared/ResizableDivider.tsx
Normal file
73
ui/src/components/shared/ResizableDivider.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface ResizableDividerProps {
|
||||
/** Current panel width in pixels */
|
||||
panelWidth: number;
|
||||
/** Called with new width */
|
||||
onResize: (width: number) => void;
|
||||
/** Min panel width */
|
||||
minWidth?: number;
|
||||
/** Max panel width */
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export function ResizableDivider({
|
||||
panelWidth,
|
||||
onResize,
|
||||
minWidth = 200,
|
||||
maxWidth = 600,
|
||||
}: ResizableDividerProps) {
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startWidth.current = panelWidth;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [panelWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!dragging.current) return;
|
||||
// Dragging left increases panel width (panel is on the right)
|
||||
const delta = startX.current - e.clientX;
|
||||
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth.current + delta));
|
||||
onResize(newWidth);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [onResize, minWidth, maxWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
width: 6,
|
||||
cursor: 'col-resize',
|
||||
background: 'var(--border-subtle)',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: 5,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--amber)'; }}
|
||||
onMouseLeave={(e) => { if (!dragging.current) (e.currentTarget as HTMLElement).style.background = 'var(--border-subtle)'; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user