feat(dashboard): redesign as a dense AI CCTV operations console
Rework the events dashboard from the sparse light-minimal look (which read as empty / unprofessional) into a professional, information-dense operations console — keeping the LIGHT direction (the dark HMI was previously rejected). - App shell: top bar with an "AI SAFETY CCTV" brand mark + product identity, and a device/camera rail (a second neutral layer) beside the detection stream. - Density: device health + filters move into the rail; the four hero KPI cards become a compact inline pill strip; the stream is snapshot-forward. - Every event row is a camera slot (evidence thumbnail, or a "무영상" placeholder) so the stream reads as a CCTV grid and rows stay aligned regardless of snapshots. - Restrained near-monochrome; indigo for interaction only; severity colour spent on the unacknowledged-critical alarm only; contrast-checked ink; reduced-motion honoured. - Pure CSS, system fonts, zero external assets (air-gap). DOM contract (ids/classes) unchanged, so polling/ack/alarm/filter logic is untouched. Verified in-browser.
This commit is contained in:
parent
fe6a144754
commit
9d25e0a2d1
4 changed files with 291 additions and 184 deletions
|
|
@ -1,210 +1,280 @@
|
|||
/* ==================================================================
|
||||
MOVE·DETECT dashboard — light, calm, minimal.
|
||||
A safety board is calm most of the time and urgent rarely, so it stays
|
||||
near-monochrome with ONE interactive accent (indigo) and spends its only
|
||||
real color on the unacknowledged-critical alarm. Soft cards, generous
|
||||
whitespace, big tabular numbers.
|
||||
Air-gapped (폐쇄망): system fonts only, no external assets fetched at runtime.
|
||||
AI SAFETY CCTV — operations console.
|
||||
Light, dense, professional. A safety board is calm most of the time and
|
||||
urgent rarely: near-monochrome chrome, indigo reserved for interaction,
|
||||
colour spent only on the unacknowledged-critical alarm. Density carries the
|
||||
"professional product" weight — a two-column console (device/camera rail +
|
||||
detection stream) with a compact KPI strip and snapshot-forward event rows.
|
||||
Accessibility (NF9): state is never colour alone — a dot/led always pairs
|
||||
with a text label. Air-gapped (폐쇄망): system fonts, zero external assets.
|
||||
================================================================== */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--bg:#f5f6f8; --card:#ffffff; --line:#e7e9ee;
|
||||
--ink:#161a20; --ink-2:#5b636e; --ink-3:#949aa4;
|
||||
/* Surfaces — content is light; chrome (top bar + rail) is a second neutral
|
||||
layer, a hair cooler and elevated with hairline borders. */
|
||||
--bg: #eef1f5; /* app canvas (cool light) */
|
||||
--panel: #ffffff; /* top bar + rail surface */
|
||||
--panel-2: #f7f9fc; /* rail inset / hover fill */
|
||||
--card: #ffffff; /* content rows/cards */
|
||||
--line: #e3e7ee; /* hairline border */
|
||||
--line-2: #edf0f5; /* faint divider */
|
||||
|
||||
--accent:#4f46e5; /* indigo — only where you act (nav, links, focus) */
|
||||
--ok:#16a34a; /* "감시 중" live-monitoring green */
|
||||
/* Ink ramp — contrast-checked on white. ink-2 ≈ 8:1, ink-3 ≈ 4.6:1. */
|
||||
--ink: #131824;
|
||||
--ink-2: #48505e;
|
||||
--ink-3: #6b7382;
|
||||
|
||||
/* Severity (reserved, semantic — never reused as a decorative color). Identity is
|
||||
always carried by a text label too (NF9: never color alone). */
|
||||
--crit:#dc2626; --high:#ea580c; --warn:#ca8a04; --info:#64748b;
|
||||
--crit-bg:#fdf3f3; --crit-line:#f4c9c9;
|
||||
/* Brand + interaction (indigo — only where you act: nav, focus, selection, primary). */
|
||||
--accent: #4f46e5;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-wash: #eceafe;
|
||||
--accent-line: #d6d2fb;
|
||||
|
||||
--border:var(--line); /* back-compat alias */
|
||||
--text:var(--ink);
|
||||
/* Live monitoring (calm green, never an alarm). */
|
||||
--ok: #15a34a;
|
||||
--ok-wash: #e7f6ec;
|
||||
|
||||
/* Severity — reserved, semantic; identity always carries a text label too. */
|
||||
--crit:#dc2626; --crit-ink:#a51d1d; --crit-bg:#fdf2f2; --crit-line:#f2cccc;
|
||||
--high:#e2560c; --high-bg:#fdf4ee; --high-line:#f5d9c4;
|
||||
--warn:#b7820a; --warn-bg:#fbf6e6;
|
||||
--info:#5b6473;
|
||||
|
||||
--font:system-ui,-apple-system,"Segoe UI","Malgun Gothic","Apple SD Gothic Neo",sans-serif;
|
||||
--mono:ui-monospace,"Cascadia Mono","Cascadia Code",Consolas,"D2Coding","Roboto Mono",monospace;
|
||||
--shadow:0 1px 2px rgba(20,24,31,.04), 0 8px 24px -18px rgba(20,24,31,.5);
|
||||
|
||||
--shadow-sm:0 1px 2px rgba(19,24,36,.05);
|
||||
--shadow: 0 1px 2px rgba(19,24,36,.04), 0 10px 26px -18px rgba(19,24,36,.42);
|
||||
--radius:12px;
|
||||
|
||||
/* z-scale (semantic, not arbitrary) */
|
||||
--z-sticky:100; --z-dropdown:200; --z-toast:400;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
html { -webkit-text-size-adjust:100%; }
|
||||
body {
|
||||
margin:0;
|
||||
font-family:var(--font);
|
||||
color:var(--ink);
|
||||
background:var(--bg);
|
||||
line-height:1.45;
|
||||
font-variant-numeric:tabular-nums;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
text-rendering:optimizeLegibility;
|
||||
}
|
||||
|
||||
/* ---- Header ------------------------------------------------------ */
|
||||
/* ---- Top bar (chrome, second neutral layer) ---------------------- */
|
||||
.app-header {
|
||||
display:flex; align-items:center; gap:1rem;
|
||||
padding:.85rem 1.6rem;
|
||||
background:var(--card); border-bottom:1px solid var(--line);
|
||||
position:sticky; top:0; z-index:var(--z-sticky);
|
||||
display:flex; align-items:center; gap:1.1rem;
|
||||
padding:.6rem 1.25rem;
|
||||
background:var(--panel);
|
||||
border-bottom:1px solid var(--line);
|
||||
box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.app-brand { display:flex; align-items:center; gap:.55rem; font-weight:700; font-size:.98rem; letter-spacing:-.01em; }
|
||||
/* Live-monitoring dot — the board is watching. Calm green, not an alarm. */
|
||||
.live-dot { width:8px; height:8px; border-radius:50%; background:var(--ok); box-shadow:0 0 0 3px rgba(22,163,74,.14); flex:none; }
|
||||
.brand-name { color:var(--ink); }
|
||||
.brand-sub { display:none; } /* the mono wordmark is gone; keep the hook harmless */
|
||||
.app-brand { display:flex; align-items:center; gap:.6rem; text-decoration:none; color:inherit; }
|
||||
.brand-mark {
|
||||
display:grid; place-items:center; width:34px; height:34px; flex:none;
|
||||
border-radius:9px; color:var(--accent);
|
||||
background:linear-gradient(180deg,#f3f2ff,#e9e7fe);
|
||||
border:1px solid var(--accent-line);
|
||||
}
|
||||
.brand-mark svg { width:20px; height:20px; display:block; }
|
||||
.brand-text { display:flex; flex-direction:column; line-height:1.12; }
|
||||
.brand-name { font-weight:750; font-size:.92rem; letter-spacing:.02em; color:var(--ink); }
|
||||
.brand-tag { font-size:.68rem; color:var(--ink-3); letter-spacing:.01em; }
|
||||
/* legacy hook from the old header — kept harmless */
|
||||
.live-dot { display:none; }
|
||||
|
||||
.app-nav { display:flex; gap:.2rem; margin-left:1.8rem; margin-right:auto; }
|
||||
.app-nav { display:flex; gap:.15rem; margin-left:.6rem; margin-right:auto; }
|
||||
.app-nav a {
|
||||
position:relative;
|
||||
color:var(--ink-2); text-decoration:none;
|
||||
font-size:.87rem; font-weight:500;
|
||||
padding:.45rem .75rem; border-radius:8px;
|
||||
position:relative; color:var(--ink-2); text-decoration:none;
|
||||
font-size:.86rem; font-weight:550; padding:.42rem .7rem; border-radius:8px;
|
||||
transition:background .15s, color .15s;
|
||||
}
|
||||
.app-nav a:hover, .app-nav a:focus-visible { background:#f1f2f5; color:var(--ink); text-decoration:none; }
|
||||
.app-nav a.is-active { color:var(--accent); }
|
||||
.app-nav a.is-active::after {
|
||||
content:""; position:absolute; left:.75rem; right:.75rem; bottom:-.86rem; height:2px;
|
||||
background:var(--accent); border-radius:2px;
|
||||
.app-nav a:hover, .app-nav a:focus-visible { background:var(--panel-2); color:var(--ink); }
|
||||
.app-nav a.is-active { color:var(--accent); background:var(--accent-wash); }
|
||||
|
||||
.app-session { display:flex; align-items:center; gap:.7rem; font-size:.84rem; color:var(--ink-2); }
|
||||
.app-session .who { color:var(--ink); font-weight:550; display:inline-flex; align-items:center; gap:.4rem; }
|
||||
.app-session .who::before {
|
||||
content:""; width:22px; height:22px; border-radius:50%; flex:none;
|
||||
background:radial-gradient(circle at 50% 38%, #fff 0 34%, transparent 35%), var(--accent-wash);
|
||||
border:1px solid var(--accent-line);
|
||||
}
|
||||
.app-session { display:flex; align-items:center; gap:.7rem; font-size:.85rem; color:var(--ink-2); }
|
||||
.app-session .who { color:var(--ink); }
|
||||
.app-session a { color:var(--accent); text-decoration:none; font-weight:500; }
|
||||
.app-session a { color:var(--accent); text-decoration:none; font-weight:550; }
|
||||
.app-session a:hover { text-decoration:underline; text-underline-offset:3px; }
|
||||
|
||||
/* ---- Buttons / fields -------------------------------------------- */
|
||||
/* ---- Controls: buttons, fields ----------------------------------- */
|
||||
button, .btn {
|
||||
font:inherit; font-size:.83rem; font-weight:500; cursor:pointer;
|
||||
padding:.42rem .8rem;
|
||||
color:var(--ink); background:#fff;
|
||||
font:inherit; font-size:.82rem; font-weight:550; cursor:pointer;
|
||||
padding:.4rem .78rem; color:var(--ink); background:var(--card);
|
||||
border:1px solid var(--line); border-radius:9px;
|
||||
transition:transform .1s, background .15s, border-color .15s;
|
||||
transition:transform .1s, background .15s, border-color .15s, box-shadow .15s;
|
||||
}
|
||||
button:hover, .btn:hover { background:#f7f8fa; border-color:#dcdfe4; }
|
||||
button:active, .btn:active { transform:scale(.98); }
|
||||
:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
button:hover, .btn:hover { background:var(--panel-2); border-color:#d3d9e2; }
|
||||
button:active, .btn:active { transform:translateY(.5px) scale(.99); }
|
||||
button:disabled { cursor:default; }
|
||||
:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:6px; }
|
||||
|
||||
input, select {
|
||||
font:inherit; font-size:.9rem; color:var(--ink);
|
||||
padding:.5rem .65rem;
|
||||
background:#fff; border:1px solid var(--line); border-radius:9px;
|
||||
font:inherit; font-size:.86rem; color:var(--ink);
|
||||
padding:.44rem .6rem; background:var(--card);
|
||||
border:1px solid var(--line); border-radius:9px;
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input::placeholder { color:var(--ink-3); }
|
||||
input:focus, select:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent); }
|
||||
label { color:var(--ink-2); font-size:.85rem; }
|
||||
input:focus, select:focus {
|
||||
outline:none; border-color:var(--accent);
|
||||
box-shadow:0 0 0 3px var(--accent-wash);
|
||||
}
|
||||
label { color:var(--ink-2); font-size:.84rem; }
|
||||
|
||||
/* ---- Page shell -------------------------------------------------- */
|
||||
.app-main { max-width:1000px; margin:0 auto; padding:2rem 1.6rem 4rem; }
|
||||
.app-main { max-width:1380px; margin:0 auto; padding:1.5rem 1.25rem 3rem; }
|
||||
|
||||
/* ---- Console: device/camera rail + detection stream -------------- */
|
||||
.console { display:grid; grid-template-columns:264px minmax(0,1fr); gap:1.25rem; align-items:start; }
|
||||
|
||||
.rail {
|
||||
position:sticky; top:calc(46px + 1.5rem);
|
||||
display:flex; flex-direction:column; gap:1.1rem;
|
||||
background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
|
||||
padding:1rem .9rem; box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.rail-block { display:flex; flex-direction:column; gap:.6rem; }
|
||||
.rail-block + .rail-block { padding-top:1rem; border-top:1px solid var(--line-2); }
|
||||
.rail-title {
|
||||
margin:0; font-size:.7rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase;
|
||||
color:var(--ink-3);
|
||||
}
|
||||
.rail-filters { display:flex; flex-direction:column; gap:.6rem; }
|
||||
.rail-filters .field { display:flex; flex-direction:column; gap:.3rem; font-size:.74rem; font-weight:600; color:var(--ink-3); letter-spacing:.02em; text-transform:uppercase; }
|
||||
.rail-filters select { text-transform:none; font-size:.85rem; font-weight:400; letter-spacing:0; color:var(--ink); }
|
||||
.filter-check { display:inline-flex; align-items:center; gap:.45rem; font-size:.85rem; color:var(--ink-2); cursor:pointer; user-select:none; }
|
||||
.filter-check input { width:15px; height:15px; accent-color:var(--accent); }
|
||||
|
||||
/* ---- Stream (main) ----------------------------------------------- */
|
||||
.stream { display:flex; flex-direction:column; min-width:0; }
|
||||
.stream-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.9rem; }
|
||||
.stream-title { display:flex; align-items:baseline; gap:.7rem; }
|
||||
h1 { font-size:1.35rem; font-weight:760; letter-spacing:-.02em; margin:0; }
|
||||
.page-head { display:flex; align-items:baseline; gap:.8rem; margin:0 0 .25rem; flex-wrap:wrap; }
|
||||
h1 { font-size:1.5rem; font-weight:750; letter-spacing:-.025em; margin:.1rem 0 0; }
|
||||
/* Live watch pill (set by app.js from poll health): green when polling, muted when stale.
|
||||
The dot is a ::before so app.js can freely overwrite the text via textContent. */
|
||||
.watch { font-size:.82rem; font-weight:600; color:var(--ok); display:inline-flex; align-items:center; gap:.35rem; }
|
||||
.watch::before { content:""; width:6px; height:6px; border-radius:50%; background:currentColor; flex:none; }
|
||||
.page-sub { color:var(--ink-3); font-size:.86rem; margin:.15rem 0 1.4rem; }
|
||||
.section-label { font-size:.72rem; font-weight:700; color:var(--ink-3); letter-spacing:.06em; text-transform:uppercase; margin:0 0 .7rem; }
|
||||
|
||||
/* Live watch pill — green when polling, muted when stale; dot breathes while live. */
|
||||
.watch { font-size:.8rem; font-weight:650; color:var(--ok); display:inline-flex; align-items:center; gap:.4rem; }
|
||||
.watch::before { content:""; width:7px; height:7px; border-radius:50%; background:currentColor; flex:none; box-shadow:0 0 0 0 currentColor; animation:watch-pulse 2.4s ease-out infinite; }
|
||||
.watch.is-stale { color:var(--ink-3); }
|
||||
.page-sub { color:var(--ink-3); font-size:.88rem; margin:.15rem 0 1.8rem; }
|
||||
.watch.is-stale::before { animation:none; }
|
||||
@keyframes watch-pulse { 0%{ box-shadow:0 0 0 0 rgba(21,163,74,.4);} 70%,100%{ box-shadow:0 0 0 6px rgba(21,163,74,0);} }
|
||||
|
||||
/* ---- Status strip: KPI tiles ------------------------------------- */
|
||||
/* Unacknowledged counts per severity, computed client-side from the same /api/events
|
||||
array the list renders (no extra endpoint). Identity is a colored dot + a text label,
|
||||
never color alone (NF9). A zero tile dims but never hides, so the strip never reflows. */
|
||||
.status-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:.85rem; margin:0 0 2rem; }
|
||||
/* ---- KPI strip: compact unacknowledged counts -------------------- */
|
||||
/* Computed client-side from the /api/events window (no extra endpoint). A tight
|
||||
inline strip of pills, not four hero cards — dense, glanceable. Zero dims, never hides. */
|
||||
#events-status.kpi-strip { display:flex; gap:.5rem; flex-wrap:wrap; }
|
||||
.stat-tile {
|
||||
background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||
padding:1.05rem 1.15rem; box-shadow:var(--shadow);
|
||||
display:inline-flex; align-items:center; gap:.5rem;
|
||||
padding:.4rem .7rem; background:var(--card);
|
||||
border:1px solid var(--line); border-radius:999px; box-shadow:var(--shadow-sm);
|
||||
}
|
||||
.stat-tile .stat-label { display:flex; align-items:center; gap:.45rem; font-size:.79rem; font-weight:500; color:var(--ink-2); }
|
||||
.stat-tile .stat-label { display:inline-flex; align-items:center; gap:.4rem; font-size:.76rem; font-weight:600; color:var(--ink-2); }
|
||||
.stat-tile .stat-label .led { width:7px; height:7px; border-radius:50%; flex:none; background:var(--dot,#c4c9d0); }
|
||||
.stat-tile .stat-label .icon { display:none; } /* the dot carries the color; the emoji is dropped in this theme */
|
||||
.stat-tile .stat-count {
|
||||
display:block; margin-top:.5rem;
|
||||
font-size:2.3rem; font-weight:730; letter-spacing:-.04em; line-height:1;
|
||||
color:var(--ink); font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.stat-tile.is-zero { opacity:.62; }
|
||||
.stat-tile .stat-label .icon { display:none; }
|
||||
.stat-tile .stat-count { font-size:1rem; font-weight:750; letter-spacing:-.02em; line-height:1; color:var(--ink); }
|
||||
.stat-tile.is-zero { opacity:.55; }
|
||||
.stat-tile.is-zero .stat-count { color:var(--ink-3); }
|
||||
.tile-critical { --dot:var(--crit); }
|
||||
.tile-high { --dot:var(--high); }
|
||||
.tile-warning { --dot:var(--warn); }
|
||||
.tile-info { --dot:var(--info); }
|
||||
/* The one moment of color: an unacknowledged critical tints its tile. */
|
||||
.tile-critical{ --dot:var(--crit);} .tile-high{ --dot:var(--high);} .tile-warning{ --dot:var(--warn);} .tile-info{ --dot:var(--info);}
|
||||
.tile-critical:not(.is-zero) { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
.tile-critical:not(.is-zero) .stat-count { color:var(--crit); }
|
||||
.tile-critical:not(.is-zero) .stat-count, .tile-critical:not(.is-zero) .stat-label { color:var(--crit-ink); }
|
||||
|
||||
/* ---- Device / camera health panel -------------------------------- */
|
||||
/* Surfaces the heartbeat telemetry the edge already reports (online/offline, fps, temp,
|
||||
throttle, config drift). A dead camera is a safety blind spot; an offline card takes the
|
||||
same red the critical alarm does. State is never color alone — the "오프라인/응답 없음"
|
||||
text carries it too (NF9). */
|
||||
.device-strip { display:flex; flex-wrap:wrap; gap:.6rem; margin:0 0 2rem; }
|
||||
.device-strip.is-empty { color:var(--ink-3); font-size:.85rem; }
|
||||
/* ---- Device / camera tree (rail) --------------------------------- */
|
||||
/* Heartbeat telemetry the edge reports (online/offline, fps, temp, throttle, drift).
|
||||
A dead camera is a safety blind spot → an offline row takes the alarm red, with the
|
||||
"오프라인/응답 없음" text carrying state alongside the dot (NF9). */
|
||||
.device-strip { display:flex; flex-direction:column; gap:.4rem; }
|
||||
.device-strip.is-empty { color:var(--ink-3); font-size:.82rem; padding:.3rem 0; }
|
||||
.device-card {
|
||||
flex:1 1 11rem; min-width:11rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:12px;
|
||||
padding:.7rem .85rem; box-shadow:var(--shadow);
|
||||
background:var(--panel-2); border:1px solid var(--line); border-radius:10px;
|
||||
padding:.55rem .65rem; transition:border-color .15s, background .15s;
|
||||
}
|
||||
.device-card .device-name { display:flex; align-items:center; gap:.45rem; font-weight:600; font-size:.9rem; }
|
||||
.device-card .device-name .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; }
|
||||
.device-card .device-meta { margin-top:.3rem; font-family:var(--mono); font-size:.76rem; color:var(--ink-3); }
|
||||
.device-card .device-warn { margin-top:.35rem; font-size:.72rem; font-weight:600; color:var(--warn); }
|
||||
.device-card .device-name { display:flex; align-items:center; gap:.45rem; font-weight:600; font-size:.85rem; color:var(--ink); }
|
||||
.device-card .device-name .dot { width:8px; height:8px; border-radius:50%; background:var(--ok); flex:none; box-shadow:0 0 0 3px var(--ok-wash); }
|
||||
.device-card .device-meta { margin-top:.28rem; padding-left:.02rem; font-family:var(--mono); font-size:.72rem; color:var(--ink-3); }
|
||||
.device-card .device-warn { margin-top:.32rem; font-size:.7rem; font-weight:650; color:var(--warn); }
|
||||
.device-card.is-offline { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
.device-card.is-offline .device-name .dot { background:var(--crit); }
|
||||
/* Device poll went stale (500 / WAF): dim the last-known cards and say so, never freeze silently. */
|
||||
.device-card.is-offline .device-name { color:var(--crit-ink); }
|
||||
.device-card.is-offline .device-name .dot { background:var(--crit); box-shadow:0 0 0 3px var(--crit-bg); }
|
||||
.device-strip.is-stale { opacity:.6; }
|
||||
.device-stale-note { flex-basis:100%; font-size:.72rem; color:var(--ink-3); }
|
||||
/* "new events arrived while paging" reload affordance — honest about a possible gap in the list. */
|
||||
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .8rem; padding:.5rem .75rem;
|
||||
font-size:.8rem; color:var(--ink-2); background:var(--card); border:1px solid var(--line); border-radius:8px; }
|
||||
.page-gap-hint[hidden] { display:none; }
|
||||
.device-stale-note { font-size:.7rem; color:var(--ink-3); padding-bottom:.2rem; }
|
||||
|
||||
/* ---- Detection stream: event rows -------------------------------- */
|
||||
.event-list { display:flex; flex-direction:column; gap:.5rem; }
|
||||
.event-list[aria-busy="true"] .stream-empty { color:var(--ink-3); }
|
||||
.stream-empty { padding:2.5rem 1rem; text-align:center; color:var(--ink-3); font-size:.9rem; }
|
||||
|
||||
/* ---- Event list: soft cards -------------------------------------- */
|
||||
.event-row {
|
||||
display:flex; align-items:center; gap:1rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:14px;
|
||||
padding:.95rem 1.15rem; margin-bottom:.6rem; box-shadow:var(--shadow);
|
||||
display:flex; align-items:center; gap:.9rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:11px;
|
||||
padding:.6rem .8rem; box-shadow:var(--shadow-sm);
|
||||
transition:border-color .15s, box-shadow .15s, transform .1s;
|
||||
}
|
||||
/* An unacknowledged critical is the only row that takes color; the tint + red badge +
|
||||
"확인 필요" chip make it unmissable without shouting. */
|
||||
.event-row.is-unacknowledged.sev-row-critical { background:var(--crit-bg); border-color:var(--crit-line); }
|
||||
.event-row:hover { border-color:#d3d9e2; box-shadow:var(--shadow); }
|
||||
/* The one row that takes colour: an unacknowledged critical — full tint + border + a soft
|
||||
attention glow (state, not decoration). Never a bare colour: badge + "확인 필요" chip too. */
|
||||
.event-row.is-unacknowledged.sev-row-critical {
|
||||
background:var(--crit-bg); border-color:var(--crit-line);
|
||||
animation:crit-attention 2.6s ease-out infinite;
|
||||
}
|
||||
.event-row.is-unacknowledged.sev-row-high { background:var(--high-bg); border-color:var(--high-line); }
|
||||
@keyframes crit-attention { 0%{ box-shadow:0 0 0 0 rgba(220,38,38,.18);} 70%,100%{ box-shadow:0 0 0 5px rgba(220,38,38,0);} }
|
||||
|
||||
/* Evidence thumbnail (newest snapshot) — a small camera still on the left of the row. */
|
||||
.event-thumb { width:64px; height:44px; object-fit:cover; border-radius:8px; border:1px solid var(--line); background:#0f1216; flex:none; }
|
||||
/* Evidence slot — a camera still on the left of every row (aligned, CCTV-grid feel). */
|
||||
.event-thumb-slot { position:relative; width:74px; height:52px; flex:none; border-radius:8px; border:1px solid var(--line); background:#0e1116; overflow:hidden; }
|
||||
.event-thumb { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
/* No snapshot (none captured, or purged by PIPA retention): an empty frame, not a black feed. */
|
||||
.event-thumb-slot.is-empty { border-style:dashed; border-color:#cdd3dc; background:var(--panel-2); }
|
||||
.event-thumb-slot.is-empty::after { content:"무영상"; position:absolute; inset:0; display:grid; place-items:center; font-size:.62rem; color:var(--ink-3); }
|
||||
|
||||
.severity-badge { display:inline-flex; align-items:center; gap:.45rem; flex:none; min-width:66px; font-size:.84rem; font-weight:600; white-space:nowrap; }
|
||||
/* app.js/detail.js build the badge as <span.icon>(emoji)</span><span.label>text</span>.
|
||||
In this theme the icon becomes a colored dot (emoji hidden) and the label carries identity. */
|
||||
.severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; }
|
||||
.sev-critical { color:var(--crit); }
|
||||
.sev-high { color:var(--high); }
|
||||
.sev-warning { color:var(--warn); }
|
||||
.sev-info { color:var(--info); }
|
||||
.severity-badge { display:inline-flex; align-items:center; gap:.42rem; flex:none; min-width:58px; font-size:.8rem; font-weight:650; white-space:nowrap; }
|
||||
.severity-badge .icon { width:9px; height:9px; border-radius:50%; background:currentColor; font-size:0; line-height:0; flex:none; }
|
||||
.sev-critical{ color:var(--crit);} .sev-high{ color:var(--high);} .sev-warning{ color:var(--warn);} .sev-info{ color:var(--info);}
|
||||
|
||||
.event-meta { flex:1; min-width:0; }
|
||||
.event-type { font-weight:650; font-size:1rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem; }
|
||||
.event-time { margin-top:.2rem; font-family:var(--mono); font-size:.79rem; color:var(--ink-3); }
|
||||
.event-type { font-weight:650; font-size:.95rem; letter-spacing:-.01em; display:flex; align-items:center; gap:.5rem; }
|
||||
.event-time { margin-top:.15rem; font-family:var(--mono); font-size:.75rem; color:var(--ink-3); }
|
||||
.event-detail-link { color:inherit; text-decoration:none; }
|
||||
.event-detail-link:hover, .event-detail-link:focus-visible { color:var(--accent); text-decoration:underline; text-underline-offset:3px; }
|
||||
/* "확인 필요" chip, appended by app.js to an unacknowledged critical row. */
|
||||
.chip { font-size:.72rem; font-weight:600; color:var(--crit); background:#fbe2e2; padding:.12rem .5rem; border-radius:999px; }
|
||||
.chip { font-size:.68rem; font-weight:700; color:#fff; background:var(--crit); padding:.14rem .5rem; border-radius:999px; letter-spacing:.01em; }
|
||||
|
||||
.ack-btn { margin-left:auto; flex:none; }
|
||||
.ack-btn:disabled { cursor:default; color:var(--ink-3); background:none; border:none; box-shadow:none; font-family:var(--mono); font-size:.8rem; }
|
||||
.ack-btn:disabled { color:var(--ink-3); background:none; border-color:transparent; box-shadow:none; font-family:var(--mono); font-size:.76rem; }
|
||||
|
||||
/* ---- Section labels --------------------------------------------- */
|
||||
.section-label { font-size:.79rem; font-weight:600; color:var(--ink-3); letter-spacing:.04em; margin:0 0 .7rem; }
|
||||
/* "new events arrived while paging" reload affordance — honest about a possible gap. */
|
||||
.page-gap-hint { display:flex; align-items:center; gap:.6rem; margin:0 0 .7rem; padding:.5rem .75rem;
|
||||
font-size:.8rem; color:var(--ink-2); background:var(--accent-wash); border:1px solid var(--accent-line); border-radius:9px; }
|
||||
.page-gap-hint[hidden] { display:none; }
|
||||
.page-gap-hint button { margin-left:auto; }
|
||||
|
||||
/* ---- List toolbar: filters + "더 보기" --------------------------- */
|
||||
.list-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.7rem; }
|
||||
.list-head .section-label { margin:0; }
|
||||
.list-filters { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||||
.list-filters select { font-size:.82rem; padding:.35rem .5rem; }
|
||||
.filter-check { display:inline-flex; align-items:center; gap:.35rem; font-size:.84rem; color:var(--ink-2); cursor:pointer; user-select:none; }
|
||||
.filter-check input { accent-color:var(--accent); }
|
||||
.list-more { display:flex; justify-content:center; margin-top:.5rem; }
|
||||
.list-more { display:flex; justify-content:center; margin-top:.8rem; }
|
||||
.list-more button[hidden] { display:none; }
|
||||
|
||||
/* legacy list toolbar hooks (filters moved into the rail) */
|
||||
.list-head { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:.7rem; }
|
||||
.list-filters { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||||
|
||||
/* ---- Login ------------------------------------------------------- */
|
||||
.login-form {
|
||||
display:flex; flex-direction:column; gap:.65rem; max-width:340px; margin:3.5rem auto 0;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:16px; padding:1.8rem;
|
||||
display:flex; flex-direction:column; gap:.7rem; max-width:360px; margin:4rem auto 0;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:16px; padding:2rem 1.9rem;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
.login-form h1 { margin:.1rem 0 .5rem; }
|
||||
.login-form h1 { margin:.1rem 0 .5rem; font-size:1.3rem; }
|
||||
.login-form label { display:flex; flex-direction:column; gap:.35rem; font-size:.8rem; font-weight:600; color:var(--ink-2); }
|
||||
.login-form button { margin-top:.4rem; padding:.6rem; font-size:.9rem; background:var(--accent); color:var(--accent-ink); border-color:var(--accent); }
|
||||
.login-form button:hover { background:#4239d6; border-color:#4239d6; }
|
||||
.form-error { color:var(--crit); font-size:.85rem; }
|
||||
|
||||
/* ---- Zone editor ------------------------------------------------- */
|
||||
|
|
@ -212,14 +282,13 @@ h1 { font-size:1.5rem; font-weight:750; letter-spacing:-.025em; margin:.1rem 0 0
|
|||
.zone-controls label { display:flex; flex-direction:column; gap:.3rem; font-size:.8rem; }
|
||||
#zone-canvas {
|
||||
border:1px solid var(--line); border-radius:12px;
|
||||
background:#0f1216 center / contain no-repeat; /* dark canvas: a camera frame reads best on dark */
|
||||
cursor:crosshair; max-width:100%;
|
||||
background:#0e1116 center / contain no-repeat; cursor:crosshair; max-width:100%;
|
||||
box-shadow:var(--shadow);
|
||||
}
|
||||
#zone-list { list-style:none; padding:0; margin:1rem 0; display:flex; flex-direction:column; gap:.4rem; }
|
||||
#zone-list li {
|
||||
display:flex; align-items:center; justify-content:space-between; gap:.75rem;
|
||||
max-width:360px; padding:.5rem .75rem;
|
||||
max-width:380px; padding:.5rem .75rem;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:10px;
|
||||
font-size:.85rem; color:var(--ink-2);
|
||||
}
|
||||
|
|
@ -227,20 +296,26 @@ h1 { font-size:1.5rem; font-weight:750; letter-spacing:-.025em; margin:.1rem 0 0
|
|||
|
||||
/* ---- Incident detail -------------------------------------------- */
|
||||
.detail-row { margin:.35rem 0; }
|
||||
.detail-row .detail-label { color:var(--ink-3); font-weight:600; font-size:.75rem; text-transform:uppercase; letter-spacing:.05em; }
|
||||
.detail-row .detail-label { color:var(--ink-3); font-weight:700; font-size:.72rem; text-transform:uppercase; letter-spacing:.05em; }
|
||||
#event-detail-snapshots { display:flex; flex-wrap:wrap; gap:.85rem; margin-top:1.2rem; }
|
||||
.detail-snapshot {
|
||||
max-width:100%; height:auto;
|
||||
border:1px solid var(--line); border-radius:12px; background:#fff; padding:4px;
|
||||
box-shadow:var(--shadow);
|
||||
.detail-snapshot { max-width:100%; height:auto; border:1px solid var(--line); border-radius:12px; background:#fff; padding:4px; box-shadow:var(--shadow); }
|
||||
|
||||
/* ---- Responsive: rail collapses above the stream ----------------- */
|
||||
@media (max-width:860px) {
|
||||
.console { grid-template-columns:1fr; }
|
||||
.rail { position:static; }
|
||||
.rail-block { flex-direction:column; }
|
||||
.rail-filters { flex-direction:row; flex-wrap:wrap; align-items:end; }
|
||||
.device-strip { flex-direction:row; flex-wrap:wrap; }
|
||||
.device-card { flex:1 1 12rem; }
|
||||
}
|
||||
@media (max-width:560px) {
|
||||
.app-main { padding:1rem .9rem 2.5rem; }
|
||||
.brand-tag { display:none; }
|
||||
.event-thumb { width:60px; height:42px; }
|
||||
}
|
||||
|
||||
/* ---- Responsive -------------------------------------------------- */
|
||||
@media (max-width:720px) {
|
||||
.status-strip { grid-template-columns:repeat(2,1fr); }
|
||||
.app-nav { margin-left:0; }
|
||||
.app-main { padding:1.5rem 1.1rem 3rem; }
|
||||
}
|
||||
@media (max-width:420px) {
|
||||
.status-strip { grid-template-columns:1fr 1fr; }
|
||||
/* ---- Reduced motion ---------------------------------------------- */
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
*, *::before, *::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,19 +128,24 @@
|
|||
var row = document.createElement('div');
|
||||
row.className = 'event-row sev-row-' + sevRow + (isAcked ? '' : ' is-unacknowledged');
|
||||
|
||||
// Evidence thumbnail (newest snapshot for this event), when one exists. The <img> goes
|
||||
// Evidence slot — EVERY row is a camera slot so rows stay aligned and the stream reads as a
|
||||
// CCTV grid. When a snapshot exists it fills the slot; otherwise (or after a PIPA-retention
|
||||
// purge) the slot shows an empty "무영상" frame instead of collapsing the row. The <img> goes
|
||||
// through /snap/{uuid}, which re-checks session + site scope, so the uuid grants nothing.
|
||||
var slot = document.createElement('div');
|
||||
slot.className = 'event-thumb-slot';
|
||||
if (ev.thumb_uuid) {
|
||||
var thumb = document.createElement('img');
|
||||
thumb.className = 'event-thumb';
|
||||
thumb.src = B + '/snap/' + encodeURIComponent(ev.thumb_uuid);
|
||||
thumb.alt = '스냅샷';
|
||||
thumb.loading = 'lazy';
|
||||
// A snapshot can be purged (PIPA retention) after the row was fetched; drop the <img>
|
||||
// rather than leave a broken-image box.
|
||||
thumb.onerror = function () { if (thumb.parentNode) thumb.parentNode.removeChild(thumb); };
|
||||
row.appendChild(thumb);
|
||||
thumb.onerror = function () { slot.classList.add('is-empty'); if (thumb.parentNode) thumb.parentNode.removeChild(thumb); };
|
||||
slot.appendChild(thumb);
|
||||
} else {
|
||||
slot.classList.add('is-empty');
|
||||
}
|
||||
row.appendChild(slot);
|
||||
|
||||
row.appendChild(buildBadge(ev.severity));
|
||||
|
||||
|
|
|
|||
|
|
@ -2,30 +2,45 @@
|
|||
require __DIR__ . '/layout.php';
|
||||
render_header('이벤트');
|
||||
?>
|
||||
<div class="page-head">
|
||||
<h1>이벤트</h1>
|
||||
<span class="watch" id="watch-status" aria-live="polite">실시간 감시 중</span>
|
||||
<div class="console">
|
||||
<aside class="rail">
|
||||
<section class="rail-block">
|
||||
<h2 class="rail-title">장치 · 카메라</h2>
|
||||
<div id="devices-root" class="device-strip" aria-live="polite" aria-label="장치·카메라 상태">불러오는 중…</div>
|
||||
</section>
|
||||
<section class="rail-block">
|
||||
<h2 class="rail-title">필터</h2>
|
||||
<div class="rail-filters">
|
||||
<label class="field">심각도
|
||||
<select id="severity-filter" aria-label="심각도 필터">
|
||||
<option value="">전체 심각도</option>
|
||||
<option value="critical">위급</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="warning">경고</option>
|
||||
<option value="info">정보</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">카메라
|
||||
<select id="camera-filter" aria-label="카메라 필터"><option value="">전체 카메라</option></select>
|
||||
</label>
|
||||
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만 보기</label>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
<section class="stream">
|
||||
<header class="stream-head">
|
||||
<div class="stream-title">
|
||||
<h1>감지 스트림</h1>
|
||||
<span class="watch" id="watch-status" aria-live="polite">실시간 감시 중</span>
|
||||
</div>
|
||||
<div id="events-status" class="kpi-strip" aria-label="미확인 이벤트 현황"></div>
|
||||
</header>
|
||||
<div id="events-root" class="event-list" aria-live="polite" aria-busy="true">
|
||||
<div class="stream-empty">감지 이벤트를 불러오는 중…</div>
|
||||
</div>
|
||||
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
|
||||
</section>
|
||||
</div>
|
||||
<p class="page-sub">감지 현황 · 5초마다 갱신</p>
|
||||
<div id="events-status" class="status-strip" aria-label="미확인 이벤트 현황"></div>
|
||||
<p class="section-label">장치 상태</p>
|
||||
<div id="devices-root" class="device-strip" aria-live="polite" aria-label="장치·카메라 상태"></div>
|
||||
<div class="list-head">
|
||||
<p class="section-label">최근 이벤트</p>
|
||||
<div class="list-filters">
|
||||
<select id="severity-filter" aria-label="심각도 필터">
|
||||
<option value="">전체 심각도</option>
|
||||
<option value="critical">위급</option>
|
||||
<option value="high">높음</option>
|
||||
<option value="warning">경고</option>
|
||||
<option value="info">정보</option>
|
||||
</select>
|
||||
<select id="camera-filter" aria-label="카메라 필터"><option value="">전체 카메라</option></select>
|
||||
<label class="filter-check"><input type="checkbox" id="unacked-only"> 미확인만</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="events-root" aria-live="polite" aria-busy="true">불러오는 중...</div>
|
||||
<div class="list-more"><button type="button" id="load-more" hidden>더 보기</button></div>
|
||||
<script src="<?= view_url('/assets/app.js') ?>"></script>
|
||||
<?php
|
||||
render_footer();
|
||||
|
|
|
|||
|
|
@ -45,10 +45,22 @@ window.APP_USER = <?= json_encode($username ?? '', JSON_UNESCAPED_UNICODE | JSON
|
|||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="app-brand">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span class="brand-name">이동 감지 관제</span>
|
||||
</div>
|
||||
<a class="app-brand" href="<?= view_url('/') ?>" aria-label="AI SAFETY CCTV 홈">
|
||||
<span class="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 8V5.5A1.5 1.5 0 0 1 5.5 4H8"/>
|
||||
<path d="M16 4h2.5A1.5 1.5 0 0 1 20 5.5V8"/>
|
||||
<path d="M20 16v2.5A1.5 1.5 0 0 1 18.5 20H16"/>
|
||||
<path d="M8 20H5.5A1.5 1.5 0 0 1 4 18.5V16"/>
|
||||
<circle cx="12" cy="12" r="3.3"/>
|
||||
<circle cx="12" cy="12" r=".6" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="brand-text">
|
||||
<span class="brand-name">AI SAFETY CCTV</span>
|
||||
<span class="brand-tag">작업장 안전 관제</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="app-nav">
|
||||
<a href="<?= view_url('/') ?>" class="app-nav-link<?= $navEvents ?>">이벤트</a>
|
||||
<a href="<?= view_url('/zones') ?>" class="app-nav-link<?= $navZones ?>">구역 설정</a>
|
||||
|
|
|
|||
Loading…
Reference in a new issue