Files
enterprise_digital-platform/NetworkView/frontend/src/components/Sidebar.tsx
T
ske087 6034a62b08 Add platform icons and fix dark mode contrast on HTTPS config page
- Portal: replace placeholder brand icon with Icon_platform.png in topbar
- NetworkView: replace placeholder sidebar logo with Icon_networkView.png
- DigiServer: fix https_config.html dark mode contrast (hardcoded light colors
  replaced with body.dark-mode CSS overrides and CSS variables)
2026-07-08 21:06:45 +03:00

259 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import * as api from '../api';
import type { Site, Room, Rack } from '../types';
interface TreeRoom extends Room {
racks: Rack[];
expanded: boolean;
}
interface TreeSite extends Site {
rooms: TreeRoom[];
expanded: boolean;
}
export default function Sidebar() {
const [tree, setTree] = useState<TreeSite[]>([]);
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<Awaited<ReturnType<typeof api.search>> | null>(null);
const [loading, setLoading] = useState(true);
const [addSiteName, setAddSiteName] = useState('');
const [showAddSite, setShowAddSite] = useState(false);
const [addSiteErr, setAddSiteErr] = useState<string | null>(null);
const navigate = useNavigate();
const location = useLocation();
const loadTree = useCallback(async () => {
const sites = await api.getSites();
setTree(sites.map(s => ({ ...s, rooms: [], expanded: false })));
setLoading(false);
}, []);
useEffect(() => { loadTree(); }, [loadTree]);
const expandSite = async (siteId: string) => {
setTree(prev => prev.map(s => {
if (s.id !== siteId) return s;
if (s.expanded) return { ...s, expanded: false };
return { ...s, expanded: true };
}));
// Load rooms if not loaded
setTree(prev => {
const site = prev.find(s => s.id === siteId);
if (site && site.rooms.length === 0) {
api.getSite(siteId).then(full => {
setTree(p => p.map(s => s.id === siteId
? { ...s, rooms: (full.rooms ?? []).map(r => ({ ...r, racks: [], expanded: false })) }
: s
));
});
}
return prev;
});
};
const expandRoom = async (siteId: string, roomId: string) => {
setTree(prev => prev.map(s => s.id !== siteId ? s : {
...s,
rooms: s.rooms.map(r => {
if (r.id !== roomId) return r;
if (r.expanded) return { ...r, expanded: false };
// Load racks
if (r.racks.length === 0) {
api.getRacks(roomId).then(racks => {
setTree(p => p.map(ss => ss.id !== siteId ? ss : {
...ss,
rooms: ss.rooms.map(rr => rr.id === roomId ? { ...rr, racks } : rr),
}));
});
}
return { ...r, expanded: true };
}),
}));
};
const handleAddSite = async (e: React.FormEvent) => {
e.preventDefault();
if (!addSiteName.trim()) return;
setAddSiteErr(null);
try {
const site = await api.createSite({ name: addSiteName.trim() });
setAddSiteName('');
setShowAddSite(false);
await loadTree();
navigate(`/sites/${site.id}`);
} catch (ex: unknown) {
setAddSiteErr(ex instanceof Error ? ex.message : String(ex));
}
};
useEffect(() => {
if (!search.trim()) { setSearchResults(null); return; }
const t = setTimeout(async () => {
const r = await api.search(search);
setSearchResults(r);
}, 300);
return () => clearTimeout(t);
}, [search]);
const isActive = (path: string) => location.pathname === path;
return (
<aside className="sidebar">
<div className="sidebar-header">
<Link to="/" className="sidebar-logo">
<img src={`${import.meta.env.BASE_URL}Icon_networkView.png`} alt="NetworkView" className="logo-icon" />
<span className="logo-text">NetworkView</span>
</Link>
</div>
<div className="sidebar-search">
<input
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="search-input"
/>
{searchResults && (
<div className="search-results">
{searchResults.sites.map(s => (
<Link key={s.id} to={`/sites/${s.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🏢</span> {s.name}
</Link>
))}
{searchResults.rooms.map(r => (
<Link key={r.id} to={`/rooms/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🚪</span> {r.name}
</Link>
))}
{searchResults.racks.map(r => (
<Link key={r.id} to={`/racks/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🗄</span> {r.name}
</Link>
))}
{searchResults.components.map(c => (
<Link key={c.id} to={`/components/${c.id}`} className="search-result-item" onClick={() => setSearch('')}>
<span className="search-result-icon">🔧</span> {c.name}
{c.ip_address && <span className="search-result-sub"> {c.ip_address}</span>}
</Link>
))}
{!searchResults.sites.length && !searchResults.rooms.length &&
!searchResults.racks.length && !searchResults.components.length && (
<div className="search-no-results">No results found</div>
)}
</div>
)}
</div>
<nav className="sidebar-nav">
<div className="sidebar-section-header">
<span>Sites</span>
<button className="icon-btn" title="Add site" onClick={() => setShowAddSite(v => !v)}>+</button>
</div>
{showAddSite && (
<>
<form onSubmit={handleAddSite} className="inline-add-form">
<input
autoFocus
type="text"
placeholder="Site name..."
value={addSiteName}
onChange={e => setAddSiteName(e.target.value)}
className="inline-input"
/>
<button type="submit" className="inline-btn">Add</button>
<button type="button" className="inline-btn secondary" onClick={() => { setShowAddSite(false); setAddSiteErr(null); }}></button>
</form>
{addSiteErr && <div className="inline-error">{addSiteErr}</div>}
</>
)}
{loading && <div className="sidebar-loading">Loading...</div>}
{tree.map(site => (
<div key={site.id} className="tree-node">
<div className={`tree-item tree-site ${isActive(`/sites/${site.id}`) ? 'active' : ''}`}>
<button className="tree-expand" onClick={() => expandSite(site.id)}>
{site.expanded ? '▾' : '▸'}
</button>
<Link to={`/sites/${site.id}`} className="tree-label">
<span className="tree-icon">🏢</span>
<span className="tree-name">{site.name}</span>
{site.room_count != null && (
<span className="tree-count">{site.room_count}</span>
)}
</Link>
</div>
{site.expanded && (
<div className="tree-children">
{site.rooms.map(room => (
<div key={room.id} className="tree-node">
<div className={`tree-item tree-room ${isActive(`/rooms/${room.id}`) ? 'active' : ''}`}>
<button className="tree-expand" onClick={() => expandRoom(site.id, room.id)}>
{room.expanded ? '▾' : '▸'}
</button>
<Link to={`/rooms/${room.id}`} className="tree-label">
<span className="tree-icon">🚪</span>
<span className="tree-name">{room.name}</span>
</Link>
</div>
{room.expanded && (
<div className="tree-children">
{room.racks.map(rack => (
<div key={rack.id} className={`tree-item tree-rack ${isActive(`/racks/${rack.id}`) ? 'active' : ''}`}>
<Link to={`/racks/${rack.id}`} className="tree-label">
<span className="tree-icon">🗄</span>
<span className="tree-name">{rack.name}</span>
<span className="tree-count">{rack.total_units}U</span>
</Link>
</div>
))}
</div>
)}
</div>
))}
{site.rooms.length === 0 && (
<div className="tree-empty">No rooms yet</div>
)}
</div>
)}
</div>
))}
{!loading && tree.length === 0 && (
<div className="sidebar-empty">
<p>No sites yet.</p>
<button className="btn-primary btn-sm" onClick={() => setShowAddSite(true)}>
+ Add your first site
</button>
</div>
)}
</nav>
<div className="sidebar-footer">
<Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}>
Settings
</Link>
<a
href="/portal-return"
className="sidebar-footer-link"
title="Back to Enterprise Portal"
>
Portal
</a>
<button
className="sidebar-footer-link sidebar-logout-btn"
onClick={() => { window.location.href = (import.meta.env.VITE_PORTAL_LOGOUT_URL as string | undefined) ?? '/logout'; }}
title="Log out of NetworkView and the Enterprise Portal"
>
Logout
</button>
</div>
</aside>
);
}