Changeset a762898 for lan-frontend/src


Ignore:
Timestamp:
01/26/26 16:19:33 (5 months ago)
Author:
istevanoska <ilinastevanoska@…>
Branches:
master
Children:
ba17441
Parents:
e4c61dd
Message:

Added visualizations

Location:
lan-frontend/src
Files:
3 added
2 edited

Legend:

Unmodified
Added
Removed
  • lan-frontend/src/App.jsx

    re4c61dd ra762898  
    1 import React, { useEffect, useMemo, useState } from "react";
     1import React, {useEffect, useMemo, useState} from "react";
    22import "./styles/theme.css";
     3import "./styles/netinel.css";
    34
    45import Login from "./components/Login";
     
    78import ComputerDetailsModal from "./components/ComputerDetailsModal";
    89import AIAssistantDrawer from "./components/AIAssistantDrawer";
     10import NetIntelViz from "./components/NetIntelViz.jsx";
     11import SideMenu from "./components/SideMenu";
     12
    913
    1014export default function App() {
    11   const [stats, setStats] = useState(null);
    12   const [computers, setComputers] = useState([]);
    13   const [loading, setLoading] = useState(true);
    14   const [lastUpdated, setLastUpdated] = useState(null);
    15 
    16   const [query, setQuery] = useState("");
    17   const [status, setStatus] = useState("");
    18 
    19   const [selectedComputer, setSelectedComputer] = useState(null);
    20   const [aiOpen, setAiOpen] = useState(false);
    21   const [aiScope, setAiScope] = useState(null);
    22   const [selectedEnv, setSelectedEnv] = useState("default");
    23 
    24   const [me, setMe] = useState(null);
    25   const [meLoading, setMeLoading] = useState(true);
    26 
    27   // NEW: environment setting (switch)
    28   const [saveProcHistory, setSaveProcHistory] = useState(false);
    29 
    30   async function loadMe() {
    31     setMeLoading(true);
    32     try {
    33       const r = await fetch("/api/me", { credentials: "include" });
    34       if (r.ok) {
    35         const data = await r.json();
    36         setMe(data.user);
    37       } else {
    38         setMe(null);
    39       }
    40     } catch (e) {
    41       console.error("loadMe error", e);
    42       setMe(null);
    43     } finally {
    44       setMeLoading(false);
     15    const [stats, setStats] = useState(null);
     16    const [computers, setComputers] = useState([]);
     17    const [loading, setLoading] = useState(true);
     18    const [lastUpdated, setLastUpdated] = useState(null);
     19
     20    const [query, setQuery] = useState("");
     21    const [status, setStatus] = useState("");
     22
     23    const [selectedComputer, setSelectedComputer] = useState(null);
     24    const [aiOpen, setAiOpen] = useState(false);
     25    const [aiScope, setAiScope] = useState(null);
     26    const [selectedEnv, setSelectedEnv] = useState("default");
     27
     28    const [me, setMe] = useState(null);
     29    const [meLoading, setMeLoading] = useState(true);
     30
     31    const [activeView, setActiveView] = useState("dashboard");
     32// "dashboard" | "visualizations" | "account"
     33
     34
     35    // NEW: environment setting (switch)
     36    const [saveProcHistory, setSaveProcHistory] = useState(false);
     37
     38    async function loadMe() {
     39        setMeLoading(true);
     40        try {
     41            const r = await fetch("/api/me", {credentials: "include"});
     42            if (r.ok) {
     43                const data = await r.json();
     44                setMe(data.user);
     45            } else {
     46                setMe(null);
     47            }
     48        } catch (e) {
     49            console.error("loadMe error", e);
     50            setMe(null);
     51        } finally {
     52            setMeLoading(false);
     53        }
    4554    }
    46   }
    47 
    48   async function refresh() {
    49     try {
    50       setLoading(true);
    51       const [s, c] = await Promise.all([
    52         fetch("/api/stats", { credentials: "include" }).then((r) => r.json()),
    53         fetch("/api/computers", {
    54           credentials: "include",
    55           headers: { "X-Env": selectedEnv },
    56         }).then((r) => r.json()),
    57       ]);
    58 
    59       setStats(s);
    60       setComputers(Array.isArray(c) ? c : []);
    61       setLastUpdated(new Date().toISOString());
    62     } catch (e) {
    63       console.error("refresh error", e);
    64     } finally {
    65       setLoading(false);
     55
     56    async function refresh() {
     57        try {
     58            setLoading(true);
     59            const [s, c] = await Promise.all([
     60                fetch("/api/stats", {credentials: "include"}).then((r) => r.json()),
     61                fetch("/api/computers", {
     62                    credentials: "include",
     63                    headers: {"X-Env": selectedEnv},
     64                }).then((r) => r.json()),
     65            ]);
     66
     67            setStats(s);
     68            setComputers(Array.isArray(c) ? c : []);
     69            setLastUpdated(new Date().toISOString());
     70        } catch (e) {
     71            console.error("refresh error", e);
     72        } finally {
     73            setLoading(false);
     74        }
    6675    }
    67   }
    68 
    69   // NEW: load env settings (switch state) for selected env
    70   async function loadEnvSettings(env) {
    71     try {
    72       // само admin ќе има пристап (ако не си admin, ќе падне во else и ќе стане false)
    73       const r = await fetch(`/api/admin/env-settings/${encodeURIComponent(env)}`, {
    74         credentials: "include",
    75       });
    76 
    77       if (r.ok) {
    78         const data = await r.json();
    79         setSaveProcHistory(!!data.save_process_history);
    80       } else {
    81         setSaveProcHistory(false);
    82       }
    83     } catch (e) {
    84       console.error("loadEnvSettings error", e);
    85       setSaveProcHistory(false);
     76
     77    // NEW: load env settings (switch state) for selected env
     78    async function loadEnvSettings(env) {
     79        try {
     80            // само admin ќе има пристап (ако не си admin, ќе падне во else и ќе стане false)
     81            const r = await fetch(`/api/admin/env-settings/${encodeURIComponent(env)}`, {
     82                credentials: "include",
     83            });
     84
     85            if (r.ok) {
     86                const data = await r.json();
     87                setSaveProcHistory(!!data.save_process_history);
     88            } else {
     89                setSaveProcHistory(false);
     90            }
     91        } catch (e) {
     92            console.error("loadEnvSettings error", e);
     93            setSaveProcHistory(false);
     94        }
    8695    }
    87   }
    88 
    89   // NEW: set env setting (toggle)
    90   async function setEnvProcessHistory(env, enabled) {
    91     // optimistic UI
    92     setSaveProcHistory(enabled);
    93 
    94     try {
    95       const r = await fetch(`/api/admin/env-settings/${encodeURIComponent(env)}`, {
    96         method: "POST",
    97         credentials: "include",
    98         headers: { "Content-Type": "application/json" },
    99         body: JSON.stringify({ save_process_history: enabled }),
    100       });
    101 
    102       if (r.ok) {
    103         const data = await r.json();
    104         setSaveProcHistory(!!data.save_process_history);
    105       } else {
    106         // rollback
    107         setSaveProcHistory(!enabled);
    108         console.error("setEnvProcessHistory failed", await r.text());
    109       }
    110     } catch (e) {
    111       setSaveProcHistory(!enabled);
    112       console.error("setEnvProcessHistory error", e);
     96
     97    // NEW: set env setting (toggle)
     98    async function setEnvProcessHistory(env, enabled) {
     99        // optimistic UI
     100        setSaveProcHistory(enabled);
     101
     102        try {
     103            const r = await fetch(`/api/admin/env-settings/${encodeURIComponent(env)}`, {
     104                method: "POST",
     105                credentials: "include",
     106                headers: {"Content-Type": "application/json"},
     107                body: JSON.stringify({save_process_history: enabled}),
     108            });
     109
     110            if (r.ok) {
     111                const data = await r.json();
     112                setSaveProcHistory(!!data.save_process_history);
     113            } else {
     114                // rollback
     115                setSaveProcHistory(!enabled);
     116                console.error("setEnvProcessHistory failed", await r.text());
     117            }
     118        } catch (e) {
     119            setSaveProcHistory(!enabled);
     120            console.error("setEnvProcessHistory error", e);
     121        }
    113122    }
    114   }
    115 
    116   // on mount: check session
    117   useEffect(() => {
    118     loadMe();
    119   }, []);
    120 
    121   // when logged in OR env changed, refresh data + load env switch state
    122   useEffect(() => {
    123     if (!me) return;
    124     loadEnvSettings(selectedEnv);
    125     refresh();
    126     // eslint-disable-next-line react-hooks/exhaustive-deps
    127   }, [me, selectedEnv]);
    128 
    129   const filtered = useMemo(() => {
    130     const q = query.trim().toLowerCase();
    131     return (computers || [])
    132       .filter((c) => (status ? c.status === status : true))
    133       .filter((c) => {
    134         if (!q) return true;
    135         const hay = [c.name, c.user, c.ip, c.os, c.status]
    136           .filter(Boolean)
    137           .join(" ")
    138           .toLowerCase();
    139         return hay.includes(q);
    140       });
    141   }, [computers, query, status]);
    142 
    143   // Gate UI
    144   if (meLoading) return <div style={{ padding: 20 }}>Loading…</div>;
    145   if (!me) return <Login onDone={loadMe} />;
    146 
    147   return (
    148     <div className="app-wrap">
    149       <TopBar
    150         onOpenAI={() => setAiOpen(true)}
    151         onRefresh={refresh}
    152         lastUpdated={lastUpdated}
    153         onEnvChange={(env) => setSelectedEnv(env)}
    154         selectedEnv={selectedEnv}
    155         me={me}
    156         onLoggedOut={loadMe}
    157         // NEW props for switch:
    158         saveProcHistory={saveProcHistory}
    159         onToggleProcHistory={(enabled) => setEnvProcessHistory(selectedEnv, enabled)}
    160       />
    161 
    162       {/* STATS */}
    163       <div className="stats-grid">
    164         <div className="stat-card">
    165           <div className="label">Total Computers</div>
    166           <div className="value">{computers.length}</div>
    167           <div className="sub">Monitored Systems</div>
     123
     124    // on mount: check session
     125    useEffect(() => {
     126        loadMe();
     127    }, []);
     128
     129    // when logged in OR env changed, refresh data + load env switch state
     130    useEffect(() => {
     131        if (!me) return;
     132        loadEnvSettings(selectedEnv);
     133        refresh();
     134        // eslint-disable-next-line react-hooks/exhaustive-deps
     135    }, [me, selectedEnv]);
     136
     137    const filtered = useMemo(() => {
     138        const q = query.trim().toLowerCase();
     139        return (computers || [])
     140            .filter((c) => (status ? c.status === status : true))
     141            .filter((c) => {
     142                if (!q) return true;
     143                const hay = [c.name, c.user, c.ip, c.os, c.status]
     144                    .filter(Boolean)
     145                    .join(" ")
     146                    .toLowerCase();
     147                return hay.includes(q);
     148            });
     149    }, [computers, query, status]);
     150
     151    // Gate UI
     152    if (meLoading) return <div style={{padding: 20}}>Loading…</div>;
     153    if (!me) return <Login onDone={loadMe}/>;
     154
     155    return (
     156        <div className="app-shell">
     157            <div className="layout">
     158                <SideMenu activeView={activeView} setActiveView={setActiveView}/>
     159                <main className="content">
     160                    {/* TopBar + pages */}
     161                </main>
     162            </div>
     163
     164            <div className="cont">
     165                <TopBar
     166                    onOpenAI={() => setAiOpen(true)}
     167                    onRefresh={refresh}
     168                    lastUpdated={lastUpdated}
     169                    onEnvChange={(env) => setSelectedEnv(env)}
     170                    selectedEnv={selectedEnv}
     171                    me={me}
     172                    onLoggedOut={loadMe}
     173                    saveProcHistory={saveProcHistory}
     174                    onToggleProcHistory={(enabled) => setEnvProcessHistory(selectedEnv, enabled)}
     175                />
     176
     177                <div className="app-main">
     178                    {/* ====== VIEW SWITCH ====== */}
     179                    {activeView === "dashboard" && (
     180                        <>
     181                            {/* STATS */}
     182                            <div className="stats-grid">
     183                                <div className="stat-card">
     184                                    <div className="label">Total Computers</div>
     185                                    <div className="value">{computers.length}</div>
     186                                    <div className="sub">Monitored Systems</div>
     187                                </div>
     188
     189                                <div className="stat-card">
     190                                    <div className="label">Sysmon Events (24h)</div>
     191                                    <div className="value">{stats?.total_sysmon_events ?? "—"}</div>
     192                                    <div className="sub">Security Events</div>
     193                                </div>
     194
     195                                <div className="stat-card">
     196                                    <div className="label">Network Connections (24h)</div>
     197                                    <div className="value">{stats?.total_network_connections ?? "—"}</div>
     198                                    <div className="sub">Captured Connections</div>
     199                                </div>
     200                            </div>
     201
     202                            {/* FILTERS */}
     203                            <div className="panel" style={{marginBottom: 14}}>
     204                                <div className="panel-head">
     205                                    <div className="panel-title">Filters</div>
     206                                    <div style={{color: "var(--dim)", fontSize: 12}}>
     207                                        {loading ? "Refreshing…" : `${filtered.length} shown`}
     208                                    </div>
     209                                </div>
     210                                <div className="panel-body">
     211                                    <div className="filters">
     212                                        <input
     213                                            className="input"
     214                                            value={query}
     215                                            onChange={(e) => setQuery(e.target.value)}
     216                                            placeholder="Search by name / user / ip / os…"
     217                                        />
     218
     219                                        <select
     220                                            className="select"
     221                                            value={status}
     222                                            onChange={(e) => setStatus(e.target.value)}
     223                                        >
     224                                            <option value="">All status</option>
     225                                            <option value="online">Online</option>
     226                                            <option value="idle">Idle</option>
     227                                            <option value="offline">Offline</option>
     228                                        </select>
     229
     230                                        <button className="btn primary" onClick={refresh}>
     231                                            Refresh
     232                                        </button>
     233                                    </div>
     234                                </div>
     235                            </div>
     236
     237                            {/* COMPUTERS */}
     238                            <div className="panel">
     239                                <div className="panel-head">
     240                                    <div className="panel-title">Connected Computers</div>
     241                                    <div style={{display: "flex", gap: 10, alignItems: "center"}}>
     242                                        <span className="pill">{filtered.length} computers</span>
     243                                        <button
     244                                            className="btn"
     245                                            onClick={() => {
     246                                                setQuery("");
     247                                                setStatus("");
     248                                            }}
     249                                        >
     250                                            Reset
     251                                        </button>
     252                                    </div>
     253                                </div>
     254                                <div className="panel-body">
     255                                    <div className="computers-grid">
     256                                        {filtered.map((c) => (
     257                                            <ComputerCard
     258                                                key={c.name}
     259                                                c={c}
     260                                                onOpen={(pc) => {
     261                                                    setSelectedComputer(pc);
     262                                                    setAiScope(pc.name);
     263                                                }}
     264                                            />
     265                                        ))}
     266                                        {filtered.length === 0 && (
     267                                            <div style={{color: "var(--dim)"}}>
     268                                                No computers match filter.
     269                                            </div>
     270                                        )}
     271                                    </div>
     272                                </div>
     273                            </div>
     274                        </>
     275                    )}
     276
     277                    {activeView === "visualizations" && (
     278                        <>
     279                            <div className="panel" style={{marginBottom: 14}}>
     280                                <div className="panel-head">
     281                                    <div className="panel-title">Visualizations</div>
     282                                    <div style={{color: "var(--dim)", fontSize: 12}}>
     283                                        Network graphs / charts / tables
     284                                    </div>
     285                                </div>
     286                                <div className="panel-body">
     287                                    <NetIntelViz baseUrl="http://localhost:5555"/>
     288                                </div>
     289                            </div>
     290                        </>
     291                    )}
     292
     293                    {activeView === "account" && (
     294                        <div className="panel">
     295                            <div className="panel-head">
     296                                <div className="panel-title">Account</div>
     297                                <div style={{color: "var(--dim)", fontSize: 12}}>
     298                                    Profile info
     299                                </div>
     300                            </div>
     301                            <div className="panel-body">
     302                                <div style={{display: "grid", gap: 10}}>
     303                                    <div className="pill">Email: {me.email}</div>
     304                                    <div className="pill">Role: {me.role}</div>
     305                                    <div className="pill">Tenant ID: {me.tenant_id}</div>
     306                                    <div className="pill">Name: {me.name || "—"}</div>
     307                                </div>
     308                            </div>
     309                        </div>
     310                    )}
     311                </div>
     312
     313                {/* DETAILS MODAL */}
     314                <ComputerDetailsModal
     315                    open={!!selectedComputer}
     316                    computer={selectedComputer}
     317                    onClose={() => setSelectedComputer(null)}
     318                    onAskAI={(name) => {
     319                        setAiScope(name);
     320                        setAiOpen(true);
     321                    }}
     322                />
     323
     324                {/* AI DRAWER */}
     325                <AIAssistantDrawer
     326                    open={aiOpen}
     327                    onClose={() => setAiOpen(false)}
     328                    computers={computers}
     329                    scope={aiScope}
     330                    setScope={setAiScope}
     331                    apiBase={import.meta.env.VITE_API_BASE}
     332                />
     333            </div>
    168334        </div>
    169 
    170         <div className="stat-card">
    171           <div className="label">Sysmon Events (24h)</div>
    172           <div className="value">{stats?.total_sysmon_events ?? "—"}</div>
    173           <div className="sub">Security Events</div>
    174         </div>
    175 
    176         <div className="stat-card">
    177           <div className="label">Network Connections (24h)</div>
    178           <div className="value">{stats?.total_network_connections ?? "—"}</div>
    179           <div className="sub">Captured Connections</div>
    180         </div>
    181       </div>
    182 
    183       {/* FILTERS */}
    184       <div className="panel" style={{ marginBottom: 14 }}>
    185         <div className="panel-head">
    186           <div className="panel-title">Filters</div>
    187           <div style={{ color: "var(--dim)", fontSize: 12 }}>
    188             {loading ? "Refreshing…" : `${filtered.length} shown`}
    189           </div>
    190         </div>
    191         <div className="panel-body">
    192           <div className="filters">
    193             <input
    194               className="input"
    195               value={query}
    196               onChange={(e) => setQuery(e.target.value)}
    197               placeholder="Search by name / user / ip / os…"
    198             />
    199 
    200             <select
    201               className="select"
    202               value={status}
    203               onChange={(e) => setStatus(e.target.value)}
    204             >
    205               <option value="">All status</option>
    206               <option value="online">Online</option>
    207               <option value="idle">Idle</option>
    208               <option value="offline">Offline</option>
    209             </select>
    210 
    211             <button className="btn primary" onClick={refresh}>
    212               Refresh
    213             </button>
    214           </div>
    215         </div>
    216       </div>
    217 
    218       {/* COMPUTERS */}
    219       <div className="panel">
    220         <div className="panel-head">
    221           <div className="panel-title">Connected Computers</div>
    222           <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
    223             <span className="pill">{filtered.length} computers</span>
    224             <button
    225               className="btn"
    226               onClick={() => {
    227                 setQuery("");
    228                 setStatus("");
    229               }}
    230             >
    231               Reset
    232             </button>
    233           </div>
    234         </div>
    235         <div className="panel-body">
    236           <div className="computers-grid">
    237             {filtered.map((c) => (
    238               <ComputerCard
    239                 key={c.name}
    240                 c={c}
    241                 onOpen={(pc) => {
    242                   setSelectedComputer(pc);
    243                   setAiScope(pc.name);
    244                 }}
    245               />
    246             ))}
    247             {filtered.length === 0 && (
    248               <div style={{ color: "var(--dim)" }}>No computers match filter.</div>
    249             )}
    250           </div>
    251         </div>
    252       </div>
    253 
    254       {/* DETAILS MODAL */}
    255       <ComputerDetailsModal
    256         open={!!selectedComputer}
    257         computer={selectedComputer}
    258         onClose={() => setSelectedComputer(null)}
    259         onAskAI={(name) => {
    260           setAiScope(name);
    261           setAiOpen(true);
    262         }}
    263       />
    264 
    265       {/* AI DRAWER */}
    266       <AIAssistantDrawer
    267         open={aiOpen}
    268         onClose={() => setAiOpen(false)}
    269         computers={computers}
    270         scope={aiScope}
    271         setScope={setAiScope}
    272         apiBase={import.meta.env.VITE_API_BASE}
    273       />
    274     </div>
    275   );
     335    );
    276336}
  • lan-frontend/src/styles/theme.css

    re4c61dd ra762898  
    33   Dark Navy + Light Blue
    44   ========================= */
     5@tailwind base;
     6@tailwind components;
     7@tailwind utilities;
    58
    69:root{
     
    548551  border-color: rgba(88, 167, 255, .35);
    549552}
     553
     554/* ====== LAYOUT with LEFT SIDEBAR ====== */
     555.app-shell {
     556  display: grid;
     557  grid-template-columns: 240px 1fr;
     558  min-height: 100vh;
     559}
     560
     561.app-main {
     562  padding: 14px;
     563}
     564
     565/* Sidebar */
     566.side {
     567  background: rgba(255,255,255,0.04);
     568  border-right: 1px solid rgba(255,255,255,0.10);
     569  padding: 14px;
     570  position: sticky;
     571  top: 0;
     572  height: 100vh;
     573}
     574
     575.side-brand {
     576  display: flex;
     577  gap: 10px;
     578  align-items: center;
     579  padding: 10px;
     580  border: 1px solid rgba(255,255,255,0.10);
     581  border-radius: 14px;
     582  background: rgba(0,0,0,0.12);
     583  margin-bottom: 14px;
     584}
     585
     586.side-brand-dot {
     587  width: 10px;
     588  height: 10px;
     589  border-radius: 999px;
     590  background: #7dd3fc;
     591  box-shadow: 0 0 0 4px rgba(125,211,252,0.15);
     592}
     593
     594.side-brand-title {
     595  font-weight: 700;
     596  letter-spacing: 0.2px;
     597}
     598
     599.side-brand-sub {
     600  font-size: 12px;
     601  color: var(--dim);
     602}
     603
     604.side-section-title {
     605  font-size: 11px;
     606  color: var(--dim);
     607  margin: 12px 8px 8px;
     608  letter-spacing: 0.12em;
     609}
     610
     611.side-item {
     612  width: 100%;
     613  display: flex;
     614  align-items: center;
     615  gap: 10px;
     616  padding: 10px 12px;
     617  border-radius: 12px;
     618  border: 1px solid transparent;
     619  background: transparent;
     620  color: inherit;
     621  cursor: pointer;
     622  text-align: left;
     623}
     624
     625.side-item:hover {
     626  background: rgba(255,255,255,0.06);
     627  border-color: rgba(255,255,255,0.08);
     628}
     629
     630.side-item.active {
     631  background: rgba(125,211,252,0.12);
     632  border-color: rgba(125,211,252,0.25);
     633}
     634
     635.side-icon {
     636  width: 26px;
     637  display: grid;
     638  place-items: center;
     639  font-size: 18px;
     640}
     641
     642.side-label {
     643  font-size: 14px;
     644  font-weight: 600;
     645}
     646
     647.side-footer {
     648  position: absolute;
     649  bottom: 12px;
     650  left: 14px;
     651  right: 14px;
     652  padding: 10px;
     653  border-radius: 12px;
     654  border: 1px solid rgba(255,255,255,0.10);
     655  background: rgba(0,0,0,0.12);
     656}
     657
     658.side-footer-hint {
     659  font-size: 12px;
     660  color: var(--dim);
     661}
     662/* ==== Collapsible Side Menu ==== */
     663.side {
     664  position: sticky;
     665  top: 0;
     666  height: 100vh;
     667  z-index: 20;
     668
     669  width: 260px;
     670  transition: width 180ms ease;
     671  border-right: 1px solid rgba(255,255,255,0.08);
     672  background: rgba(6, 12, 24, 0.72);
     673  backdrop-filter: blur(10px);
     674}
     675
     676.side.collapsed {
     677  width: 54px; /* само тенка лента */
     678}
     679
     680.side-inner {
     681  height: 100%;
     682  padding: 14px 10px;
     683  display: flex;
     684  flex-direction: column;
     685}
     686
     687.side-handle {
     688  position: absolute;
     689  left: 6px;
     690  top: 10px;
     691  width: 42px;
     692  height: 34px;
     693  border-radius: 12px;
     694  border: 1px solid rgba(255,255,255,0.12);
     695  background: rgba(255,255,255,0.06);
     696  color: rgba(255,255,255,0.9);
     697  cursor: pointer;
     698}
     699
     700.side-brand {
     701  display: flex;
     702  align-items: center;
     703  gap: 10px;
     704  padding: 10px;
     705  margin-top: 38px; /* за да не се судри со handle */
     706  border-radius: 16px;
     707  background: rgba(255,255,255,0.03);
     708  border: 1px solid rgba(255,255,255,0.06);
     709}
     710
     711.side-brand-dot {
     712  width: 12px;
     713  height: 12px;
     714  border-radius: 999px;
     715  background: rgba(80, 160, 255, 0.95);
     716  box-shadow: 0 0 18px rgba(80,160,255,0.45);
     717}
     718
     719.side-brand-text { min-width: 0; }
     720.side-brand-title { font-weight: 800; color: rgba(255,255,255,0.92); }
     721.side-brand-sub { font-size: 12px; color: rgba(255,255,255,0.55); }
     722
     723.side-section { margin-top: 14px; }
     724.side-section-title {
     725  font-size: 11px;
     726  letter-spacing: 0.12em;
     727  color: rgba(255,255,255,0.45);
     728  padding: 10px;
     729}
     730
     731.side-item {
     732  width: 100%;
     733  display: flex;
     734  align-items: center;
     735  gap: 10px;
     736  padding: 10px 12px;
     737  margin: 6px 0;
     738  border-radius: 14px;
     739  border: 1px solid transparent;
     740  background: transparent;
     741  color: rgba(255,255,255,0.82);
     742  cursor: pointer;
     743  text-align: left;
     744}
     745
     746.side-item:hover {
     747  background: rgba(255,255,255,0.06);
     748  border-color: rgba(255,255,255,0.08);
     749}
     750
     751.side-item.active {
     752  background: rgba(80,160,255,0.16);
     753  border-color: rgba(80,160,255,0.25);
     754}
     755
     756.side-icon {
     757  width: 28px;
     758  display: grid;
     759  place-items: center;
     760  font-size: 18px;
     761}
     762
     763.side-label {
     764  white-space: nowrap;
     765  overflow: hidden;
     766  text-overflow: ellipsis;
     767}
     768
     769.side-footer {
     770  margin-top: auto;
     771  padding: 10px;
     772  color: rgba(255,255,255,0.55);
     773  font-size: 12px;
     774}
     775.layout { display: flex; }
     776.content { flex: 1; min-width: 0; }
     777/* collapsed = центрирање иконите + тргање празни простори */
     778.side.collapsed .side-inner {
     779  padding: 10px 6px;
     780  align-items: center;
     781}
     782
     783.side.collapsed .side-brand {
     784  width: 42px;
     785  padding: 10px 0;
     786  justify-content: center;
     787  margin-top: 48px;
     788}
     789
     790.side.collapsed .side-item {
     791  justify-content: center;
     792  padding: 10px 0;
     793}
     794
     795.side.collapsed .side-icon {
     796  width: 42px;
     797}
     798
     799/* handle во средина (поубаво) */
     800.side-handle {
     801  left: 6px;
     802  top: 10px;
     803  width: 42px;
     804  height: 34px;
     805}
     806
     807/* кога е open да си е нормално */
     808.side.open .side-inner {
     809  align-items: stretch;
     810}
     811
     812.cont{
     813  margin-right: 50px;
     814}
Note: See TracChangeset for help on using the changeset viewer.