chore: remove legacy simulator pages and mobile app scaffold
@@ -175,26 +175,6 @@ OpenAPI docs are generated from Zod/OpenAPI definitions:
|
|||||||
| `GET /openapi.json` | OpenAPI 3 spec (JSON) |
|
| `GET /openapi.json` | OpenAPI 3 spec (JSON) |
|
||||||
| `GET /docs` | Swagger UI |
|
| `GET /docs` | Swagger UI |
|
||||||
|
|
||||||
### Web Mobile Simulator
|
|
||||||
Use `GET /sim/mobile-sim.html` to run the full single-page browser simulator that behaves like the mobile app.
|
|
||||||
|
|
||||||
Split-page entrypoints are also available:
|
|
||||||
- `GET /sim/mobile-sim-auth.html`
|
|
||||||
- `GET /sim/mobile-sim-onboarding.html`
|
|
||||||
- `GET /sim/mobile-sim-camera.html`
|
|
||||||
- `GET /sim/mobile-sim-client.html`
|
|
||||||
- `GET /sim/mobile-sim-activity.html`
|
|
||||||
- `GET /sim/mobile-sim-settings.html`
|
|
||||||
|
|
||||||
Architecture reference page:
|
|
||||||
- `GET /sim/backend-architecture.html`
|
|
||||||
|
|
||||||
All simulator pages support the same flow:
|
|
||||||
- Register as `camera` or `client`
|
|
||||||
- Connect Socket.IO with bearer device token
|
|
||||||
- Camera: process incoming stream requests, negotiate WebRTC, start/end motion events
|
|
||||||
- Client: create links, request streams, and negotiate WebRTC viewing
|
|
||||||
|
|
||||||
### Admin Dashboard
|
### Admin Dashboard
|
||||||
Access `/admin` with Basic auth to:
|
Access `/admin` with Basic auth to:
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ app.all('/api/auth/*splat', corsMiddleware, toNodeHandler(auth));
|
|||||||
app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 }));
|
app.use(rateLimit({ keyPrefix: 'global', windowMs: 60_000, max: 400 }));
|
||||||
app.use(requestContext);
|
app.use(requestContext);
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/sim', express.static('public'));
|
|
||||||
app.use('/videos', videosRoutes);
|
app.use('/videos', videosRoutes);
|
||||||
app.use('/admin', adminRoutes);
|
app.use('/admin', adminRoutes);
|
||||||
app.use('/devices', rateLimit({ keyPrefix: 'devices', windowMs: 60_000, max: 120 }), devicesRoutes);
|
app.use('/devices', rateLimit({ keyPrefix: 'devices', windowMs: 60_000, max: 120 }), devicesRoutes);
|
||||||
|
|||||||
@@ -1,934 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Backend Architecture Deep Dive</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #071015;
|
|
||||||
--panel: #0e1b22;
|
|
||||||
--panel-2: #132731;
|
|
||||||
--text: #dbe7ef;
|
|
||||||
--muted: #94adbb;
|
|
||||||
--line: #255061;
|
|
||||||
--accent: #22c55e;
|
|
||||||
--accent-2: #06b6d4;
|
|
||||||
--warn: #f59e0b;
|
|
||||||
--danger: #f97316;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
|
||||||
color: var(--text);
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at 0% 0%, #113141 0%, transparent 45%),
|
|
||||||
radial-gradient(circle at 100% 0%, #17303a 0%, transparent 45%),
|
|
||||||
var(--bg);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrap {
|
|
||||||
max-width: 1320px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: linear-gradient(165deg, #122734 0%, #0b171e 60%);
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(1.5rem, 2vw, 2.2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: clamp(1.2rem, 1.6vw, 1.6rem);
|
|
||||||
margin-top: 26px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #d2effb;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p, li {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
strong, b {
|
|
||||||
color: #eaf7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
code, .mono {
|
|
||||||
font-family: "IBM Plex Mono", monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
background: #0a151b;
|
|
||||||
border: 1px solid #1b3744;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #b7f5ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: #8aa1af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.g2 {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.g3 {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: linear-gradient(180deg, var(--panel-2), var(--panel));
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
display: inline-block;
|
|
||||||
border: 1px solid #2e6074;
|
|
||||||
color: #9ed4ea;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin: 0 4px 6px 0;
|
|
||||||
background: #10232d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip.warn {
|
|
||||||
border-color: #7a5b1c;
|
|
||||||
color: #ffd58a;
|
|
||||||
background: #20190c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip.ok {
|
|
||||||
border-color: #1f6a42;
|
|
||||||
color: #8df0bc;
|
|
||||||
background: #0b1d15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip.alt {
|
|
||||||
border-color: #23617b;
|
|
||||||
color: #8cd7f8;
|
|
||||||
background: #0d1f29;
|
|
||||||
}
|
|
||||||
|
|
||||||
.svg-box {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #0a161d;
|
|
||||||
padding: 10px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
min-width: 840px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: #0b171e;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
padding: 9px 10px;
|
|
||||||
border-bottom: 1px solid #1f3c4b;
|
|
||||||
vertical-align: top;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: #132833;
|
|
||||||
color: #d9f4ff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
details {
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: #0b171e;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: #cde9f8;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.seq {
|
|
||||||
white-space: pre;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 14px;
|
|
||||||
background: #081117;
|
|
||||||
color: #b4d4e2;
|
|
||||||
font-family: "IBM Plex Mono", monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc {
|
|
||||||
position: sticky;
|
|
||||||
top: 10px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #0a161d;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc a {
|
|
||||||
color: #9dc9dd;
|
|
||||||
text-decoration: none;
|
|
||||||
display: block;
|
|
||||||
padding: 3px 2px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc a:hover {
|
|
||||||
color: #d4f3ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 260px minmax(0, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
|
||||||
.layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toc {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
min-width: 760px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrap">
|
|
||||||
<section class="hero">
|
|
||||||
<div class="small">SecureCam Backend Architecture</div>
|
|
||||||
<h1>Backend Architecture Deep Dive (Single-Page Reference)</h1>
|
|
||||||
<p>
|
|
||||||
This document explains how the backend is built, how requests and realtime events move through the system,
|
|
||||||
how data is stored, and where each concern lives in code.
|
|
||||||
It is based on the current implementation in this repository (<span class="mono">index.ts</span>,
|
|
||||||
<span class="mono">routes/*</span>, <span class="mono">realtime/gateway.ts</span>,
|
|
||||||
<span class="mono">services/*</span>, <span class="mono">workers/*</span>, <span class="mono">db/schema.ts</span>).
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<span class="chip ok">Express 5 API</span>
|
|
||||||
<span class="chip alt">Socket.IO Realtime</span>
|
|
||||||
<span class="chip">Better Auth + Drizzle</span>
|
|
||||||
<span class="chip">PostgreSQL</span>
|
|
||||||
<span class="chip">MinIO / S3-compatible</span>
|
|
||||||
<span class="chip warn">Mock Media Provider</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="layout">
|
|
||||||
<aside class="toc">
|
|
||||||
<strong>Contents</strong>
|
|
||||||
<a href="#system">1. System Context</a>
|
|
||||||
<a href="#startup">2. Startup Sequence</a>
|
|
||||||
<a href="#pipeline">3. HTTP Pipeline</a>
|
|
||||||
<a href="#auth">4. Auth + Identity</a>
|
|
||||||
<a href="#realtime">5. Realtime Gateway</a>
|
|
||||||
<a href="#stream">6. Stream Lifecycle</a>
|
|
||||||
<a href="#data">7. Data Model</a>
|
|
||||||
<a href="#routes">8. Route Surface</a>
|
|
||||||
<a href="#workers">9. Workers + Reliability</a>
|
|
||||||
<a href="#security">10. Security Controls</a>
|
|
||||||
<a href="#config">11. Configuration</a>
|
|
||||||
<a href="#code-map">12. Code Ownership Map</a>
|
|
||||||
<a href="#constraints">13. Current Constraints</a>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section id="system">
|
|
||||||
<h2>1) System Context</h2>
|
|
||||||
<div class="svg-box">
|
|
||||||
<svg viewBox="0 0 1220 560" role="img" aria-label="System context architecture diagram">
|
|
||||||
<defs>
|
|
||||||
<marker id="arr" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
|
||||||
<polygon points="0 0, 8 4, 0 8" fill="#6ec9e8" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect x="20" y="20" width="280" height="170" rx="12" fill="#132833" stroke="#2d5d71"/>
|
|
||||||
<text x="36" y="52" fill="#d7f2ff" font-size="18" font-weight="600">Clients</text>
|
|
||||||
<text x="36" y="84" fill="#95bacb" font-size="15">- Browser simulator (camera/client)</text>
|
|
||||||
<text x="36" y="109" fill="#95bacb" font-size="15">- Future mobile apps</text>
|
|
||||||
<text x="36" y="134" fill="#95bacb" font-size="15">- Admin browser</text>
|
|
||||||
<text x="36" y="159" fill="#95bacb" font-size="15">- Swagger/OpenAPI consumers</text>
|
|
||||||
|
|
||||||
<rect x="20" y="230" width="280" height="140" rx="12" fill="#1f2a20" stroke="#3d7249"/>
|
|
||||||
<text x="36" y="262" fill="#dcffe5" font-size="18" font-weight="600">Identity</text>
|
|
||||||
<text x="36" y="292" fill="#a8d8b6" font-size="15">Better Auth session cookies</text>
|
|
||||||
<text x="36" y="317" fill="#a8d8b6" font-size="15">Custom HMAC device bearer tokens</text>
|
|
||||||
<text x="36" y="342" fill="#a8d8b6" font-size="15">Role-aware device auth</text>
|
|
||||||
|
|
||||||
<rect x="360" y="20" width="530" height="390" rx="14" fill="#0f1e27" stroke="#2f6176"/>
|
|
||||||
<text x="380" y="52" fill="#def6ff" font-size="20" font-weight="700">Backend Process (Bun + Express + Socket.IO)</text>
|
|
||||||
|
|
||||||
<rect x="380" y="70" width="235" height="120" rx="10" fill="#13303d" stroke="#2e647a"/>
|
|
||||||
<text x="396" y="98" fill="#d7f2ff" font-size="16" font-weight="600">HTTP Layer</text>
|
|
||||||
<text x="396" y="122" fill="#9fc8da" font-size="14">Helmet + CORS + rate limits</text>
|
|
||||||
<text x="396" y="143" fill="#9fc8da" font-size="14">requestContext metrics + logs</text>
|
|
||||||
<text x="396" y="164" fill="#9fc8da" font-size="14">REST routes + OpenAPI docs</text>
|
|
||||||
|
|
||||||
<rect x="640" y="70" width="230" height="120" rx="10" fill="#13303d" stroke="#2e647a"/>
|
|
||||||
<text x="658" y="98" fill="#d7f2ff" font-size="16" font-weight="600">Realtime Gateway</text>
|
|
||||||
<text x="658" y="122" fill="#9fc8da" font-size="14">Socket.IO device rooms</text>
|
|
||||||
<text x="658" y="143" fill="#9fc8da" font-size="14">command / stream / webrtc signals</text>
|
|
||||||
<text x="658" y="164" fill="#9fc8da" font-size="14">presence + retry loop</text>
|
|
||||||
|
|
||||||
<rect x="380" y="210" width="235" height="180" rx="10" fill="#1d2e23" stroke="#447551"/>
|
|
||||||
<text x="396" y="238" fill="#e3ffe8" font-size="16" font-weight="600">Service / Worker Layer</text>
|
|
||||||
<text x="396" y="262" fill="#b8dfc3" font-size="14">push queue worker</text>
|
|
||||||
<text x="396" y="282" fill="#b8dfc3" font-size="14">recording timeout reconciler</text>
|
|
||||||
<text x="396" y="302" fill="#b8dfc3" font-size="14">audit logging</text>
|
|
||||||
<text x="396" y="322" fill="#b8dfc3" font-size="14">health + metrics endpoints</text>
|
|
||||||
<text x="396" y="342" fill="#b8dfc3" font-size="14">media provider adapter</text>
|
|
||||||
|
|
||||||
<rect x="640" y="210" width="230" height="180" rx="10" fill="#2f271d" stroke="#7a5c2f"/>
|
|
||||||
<text x="658" y="238" fill="#fff1da" font-size="16" font-weight="600">Media Control Plane</text>
|
|
||||||
<text x="658" y="262" fill="#f2d7aa" font-size="14">stream session orchestration</text>
|
|
||||||
<text x="658" y="282" fill="#f2d7aa" font-size="14">mock credential issuance</text>
|
|
||||||
<text x="658" y="302" fill="#f2d7aa" font-size="14">optional SFU scaffold (noop)</text>
|
|
||||||
<text x="658" y="322" fill="#f2d7aa" font-size="14">recording row lifecycle</text>
|
|
||||||
|
|
||||||
<rect x="940" y="40" width="260" height="160" rx="12" fill="#192b35" stroke="#39677d"/>
|
|
||||||
<text x="958" y="70" fill="#daf4ff" font-size="18" font-weight="600">PostgreSQL</text>
|
|
||||||
<text x="958" y="98" fill="#9fc5d8" font-size="14">users, devices, links, commands</text>
|
|
||||||
<text x="958" y="118" fill="#9fc5d8" font-size="14">streams, recordings, events</text>
|
|
||||||
<text x="958" y="138" fill="#9fc5d8" font-size="14">videos, notifications, audit</text>
|
|
||||||
<text x="958" y="158" fill="#9fc5d8" font-size="14">Better Auth tables</text>
|
|
||||||
|
|
||||||
<rect x="940" y="240" width="260" height="160" rx="12" fill="#2b2318" stroke="#7c6039"/>
|
|
||||||
<text x="958" y="270" fill="#fff0d4" font-size="18" font-weight="600">MinIO / Object Storage</text>
|
|
||||||
<text x="958" y="298" fill="#ebd3aa" font-size="14">presigned PUT / GET URLs</text>
|
|
||||||
<text x="958" y="318" fill="#ebd3aa" font-size="14">video objects + recordings</text>
|
|
||||||
<text x="958" y="338" fill="#ebd3aa" font-size="14">bucket bootstrap on startup</text>
|
|
||||||
|
|
||||||
<line x1="300" y1="108" x2="360" y2="108" stroke="#6ec9e8" stroke-width="2" marker-end="url(#arr)"/>
|
|
||||||
<line x1="300" y1="298" x2="360" y2="138" stroke="#7ee2aa" stroke-width="2" marker-end="url(#arr)"/>
|
|
||||||
<line x1="890" y1="120" x2="940" y2="120" stroke="#6ec9e8" stroke-width="2" marker-end="url(#arr)"/>
|
|
||||||
<line x1="890" y1="290" x2="940" y2="300" stroke="#f1bf73" stroke-width="2" marker-end="url(#arr)"/>
|
|
||||||
<line x1="700" y1="190" x2="700" y2="210" stroke="#6ec9e8" stroke-width="2" marker-end="url(#arr)"/>
|
|
||||||
<line x1="500" y1="190" x2="500" y2="210" stroke="#6ec9e8" stroke-width="2" marker-end="url(#arr)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="grid g3" style="margin-top:10px;">
|
|
||||||
<div class="card"><h3>Core Role</h3><p>Acts primarily as a <strong>control plane</strong> for auth, command routing, stream state, credential issuance, and recording metadata. It is not yet a full production media plane.</p></div>
|
|
||||||
<div class="card"><h3>Transport Split</h3><p><strong>HTTP</strong> handles CRUD/state endpoints; <strong>Socket.IO</strong> handles realtime command delivery, acknowledgements, and WebRTC signaling relay.</p></div>
|
|
||||||
<div class="card"><h3>Persistence Split</h3><p><strong>Postgres</strong> stores state + metadata. <strong>MinIO</strong> stores binary objects. Routes often coordinate both.</p></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="startup">
|
|
||||||
<h2>2) Startup Sequence</h2>
|
|
||||||
<div class="seq">Process start
|
|
||||||
-> load module graph (auth, db, minio, routes)
|
|
||||||
-> create Express app + OpenAPI doc
|
|
||||||
-> mount middleware and routes
|
|
||||||
-> create HTTP server
|
|
||||||
-> start() called
|
|
||||||
-> ensureMinioBucket()
|
|
||||||
- checks bucket existence
|
|
||||||
- creates bucket if missing
|
|
||||||
- exits process on failure
|
|
||||||
-> setupRealtimeGateway(server)
|
|
||||||
- Socket.IO auth middleware
|
|
||||||
- event handlers
|
|
||||||
- command retry interval init (if required tables exist)
|
|
||||||
-> startRecordingsWorker()
|
|
||||||
- interval scans stale awaiting_upload rows -> failed
|
|
||||||
-> startPushWorker()
|
|
||||||
- interval dispatches queued push rows
|
|
||||||
-> server.listen(PORT)
|
|
||||||
</div>
|
|
||||||
<p class="small">If MinIO initialization fails, process exits with code 1 by design (<span class="mono">index.ts</span>).</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="pipeline">
|
|
||||||
<h2>3) HTTP Request Pipeline (Express)</h2>
|
|
||||||
<div class="svg-box">
|
|
||||||
<svg viewBox="0 0 1220 360" role="img" aria-label="HTTP middleware pipeline diagram">
|
|
||||||
<defs>
|
|
||||||
<marker id="arr2" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
|
||||||
<polygon points="0 0, 8 4, 0 8" fill="#90ddff" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
<rect x="20" y="128" width="130" height="80" rx="10" fill="#122733" stroke="#2b5e74"/>
|
|
||||||
<text x="42" y="173" fill="#d6f2ff" font-size="16">Client</text>
|
|
||||||
|
|
||||||
<rect x="190" y="70" width="160" height="190" rx="10" fill="#132a35" stroke="#2f6074"/>
|
|
||||||
<text x="205" y="100" fill="#ddf6ff" font-size="15" font-weight="600">helmet()</text>
|
|
||||||
<text x="205" y="124" fill="#9ec5d6" font-size="13">CSP, headers</text>
|
|
||||||
<text x="205" y="154" fill="#ddf6ff" font-size="15" font-weight="600">cors()</text>
|
|
||||||
<text x="205" y="178" fill="#9ec5d6" font-size="13">trusted origins</text>
|
|
||||||
<text x="205" y="208" fill="#ddf6ff" font-size="15" font-weight="600">global rate limit</text>
|
|
||||||
<text x="205" y="232" fill="#9ec5d6" font-size="13">memory buckets</text>
|
|
||||||
|
|
||||||
<rect x="390" y="70" width="200" height="190" rx="10" fill="#132a35" stroke="#2f6074"/>
|
|
||||||
<text x="408" y="100" fill="#ddf6ff" font-size="15" font-weight="600">requestContext</text>
|
|
||||||
<text x="408" y="124" fill="#9ec5d6" font-size="13">x-request-id</text>
|
|
||||||
<text x="408" y="144" fill="#9ec5d6" font-size="13">counter increment</text>
|
|
||||||
<text x="408" y="164" fill="#9ec5d6" font-size="13">JSON finish log</text>
|
|
||||||
<text x="408" y="198" fill="#ddf6ff" font-size="15" font-weight="600">express.json()</text>
|
|
||||||
<text x="408" y="222" fill="#9ec5d6" font-size="13">body parsing</text>
|
|
||||||
|
|
||||||
<rect x="630" y="52" width="270" height="226" rx="10" fill="#1d2e23" stroke="#447551"/>
|
|
||||||
<text x="648" y="82" fill="#e6ffea" font-size="15" font-weight="600">Route layer</text>
|
|
||||||
<text x="648" y="106" fill="#badfc5" font-size="13">/api/auth/* (Better Auth)</text>
|
|
||||||
<text x="648" y="126" fill="#badfc5" font-size="13">/videos /devices /commands ...</text>
|
|
||||||
<text x="648" y="146" fill="#badfc5" font-size="13">route-level auth + zod validation</text>
|
|
||||||
<text x="648" y="166" fill="#badfc5" font-size="13">DB + MinIO + realtime side effects</text>
|
|
||||||
<text x="648" y="200" fill="#e6ffea" font-size="15" font-weight="600">Static + docs</text>
|
|
||||||
<text x="648" y="224" fill="#badfc5" font-size="13">/sim, /docs, /openapi.json</text>
|
|
||||||
|
|
||||||
<rect x="940" y="90" width="260" height="150" rx="10" fill="#2a2118" stroke="#7d613a"/>
|
|
||||||
<text x="958" y="120" fill="#fff2d9" font-size="15" font-weight="600">Response + Error Handler</text>
|
|
||||||
<text x="958" y="146" fill="#edd6ad" font-size="13">successful JSON / HTML / static file</text>
|
|
||||||
<text x="958" y="168" fill="#edd6ad" font-size="13">or fallback 500 JSON</text>
|
|
||||||
<text x="958" y="190" fill="#edd6ad" font-size="13">{ message: "Internal server error" }</text>
|
|
||||||
|
|
||||||
<line x1="150" y1="168" x2="190" y2="168" stroke="#90ddff" stroke-width="2" marker-end="url(#arr2)"/>
|
|
||||||
<line x1="350" y1="168" x2="390" y2="168" stroke="#90ddff" stroke-width="2" marker-end="url(#arr2)"/>
|
|
||||||
<line x1="590" y1="168" x2="630" y2="168" stroke="#90ddff" stroke-width="2" marker-end="url(#arr2)"/>
|
|
||||||
<line x1="900" y1="168" x2="940" y2="168" stroke="#90ddff" stroke-width="2" marker-end="url(#arr2)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="auth">
|
|
||||||
<h2>4) Authentication and Identity Model</h2>
|
|
||||||
<div class="grid g2">
|
|
||||||
<div class="card">
|
|
||||||
<h3>A) Session auth (<span class="mono">requireAuth</span>)</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Used by user-facing REST routes like <span class="mono">/videos</span>, <span class="mono">/devices/register</span>, <span class="mono">/device-links</span>.</li>
|
|
||||||
<li>Reads Better Auth session from request headers/cookies via <span class="mono">auth.api.getSession()</span>.</li>
|
|
||||||
<li>Attaches session object to <span class="mono">req.auth</span>.</li>
|
|
||||||
<li>Backed by Better Auth tables: <span class="mono">users</span>, <span class="mono">account</span>, <span class="mono">session</span>, <span class="mono">verification</span>.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>B) Device auth (<span class="mono">requireDeviceAuth</span>)</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Used by device-to-backend routes and Socket.IO auth.</li>
|
|
||||||
<li>Bearer token format: <span class="mono">base64url(payload).hmac</span>.</li>
|
|
||||||
<li>Payload fields: <span class="mono">userId</span>, <span class="mono">deviceId</span>, <span class="mono">role</span>, <span class="mono">exp</span>.</li>
|
|
||||||
<li>Signed with HMAC-SHA256 using <span class="mono">BETTER_AUTH_SECRET</span>.</li>
|
|
||||||
<li>Token role is verified against device role in realtime handshake.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="small">This dual model separates user session identity from per-device identity and permissions.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="realtime">
|
|
||||||
<h2>5) Realtime Gateway (Socket.IO)</h2>
|
|
||||||
<div class="grid g2">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Connection model</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Devices authenticate with token in <span class="mono">handshake.auth.token</span> or <span class="mono">Authorization</span> header.</li>
|
|
||||||
<li>Each device joins room <span class="mono">device:{deviceId}</span>.</li>
|
|
||||||
<li>Presence updates <span class="mono">devices.status</span> + <span class="mono">lastSeenAt</span>.</li>
|
|
||||||
<li>Disconnect applies a 500ms delay to reduce status flapping on fast reconnect.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>Gateway responsibilities</h3>
|
|
||||||
<ul>
|
|
||||||
<li><span class="mono">command:received</span> delivery to target room.</li>
|
|
||||||
<li><span class="mono">command:ack</span> validation + DB update + source notification.</li>
|
|
||||||
<li><span class="mono">webrtc:signal</span> relay with stream-participant validation.</li>
|
|
||||||
<li><span class="mono">stream:requested</span>, <span class="mono">stream:started</span>, and <span class="mono">stream:ended</span> lifecycle fan-out.</li>
|
|
||||||
<li>Legacy command retries remain only for non-stream commands while <span class="mono">SIMPLE_STREAMING</span> is enabled.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>Command dispatch and ack sequence</h3>
|
|
||||||
<div class="seq">Client Device API /commands DB Socket.IO Gateway Camera Device
|
|
||||||
| | | | |
|
|
||||||
1) POST /commands -------->| validate/link ---->| insert queued command | |
|
|
||||||
| | dispatchCommandById() | |
|
|
||||||
| |-----------------------------------------------> emit command:received --->|
|
|
||||||
| | | status sent/queued | |
|
|
||||||
|<--------------------| command payload | | |
|
|
||||||
|
|
||||||
2) camera emits command:ack --------------------------------------------------------------->|
|
|
||||||
| | | | validate + update DB |
|
|
||||||
| | | command status + ack time | emit command:status -->| (to source room)
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="stream">
|
|
||||||
<h2>6) Stream Lifecycle and Media Control</h2>
|
|
||||||
<div class="card">
|
|
||||||
<h3>State machine (stream session)</h3>
|
|
||||||
<div class="seq">requested --> streaming --> completed|cancelled|failed
|
|
||||||
| | |
|
|
||||||
| | +-- create recording placeholder row on end
|
|
||||||
| +-- media session created (provider + endpoints)
|
|
||||||
+-- command start_stream queued/dispatched to camera
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3>On-demand stream end-to-end sequence</h3>
|
|
||||||
<div class="seq">Client Device /streams/request Camera Device /streams/:id/accept Media Provider
|
|
||||||
| | | | |
|
|
||||||
1) request stream -------------->| validate roles/link -> | | |
|
|
||||||
| | create stream_session | | |
|
|
||||||
| | create start_stream cmd | | |
|
|
||||||
| | dispatch via socket ----+-------------------------> | command:received |
|
|
||||||
|<--------------------------| stream:requested event | | |
|
|
||||||
|
|
||||||
2) accept command (camera side)
|
|
||||||
| | | POST /accept -----------> | createSession() ------>|
|
|
||||||
| | | | status=streaming |
|
|
||||||
|<--------------------------| stream:started event ---+ | |
|
|
||||||
|
|
||||||
3) credential issuance
|
|
||||||
camera -> GET /publish-credentials -> mediaProvider.issuePublishCredentials()
|
|
||||||
viewer -> GET /subscribe-credentials -> mediaProvider.issueSubscribeCredentials()
|
|
||||||
|
|
||||||
4) stream end
|
|
||||||
camera/requester -> POST /end -> mark ended + optional sfuService.endSession() + createRecordingForStream()
|
|
||||||
-> emit stream:ended to camera and requester (push fallback if offline)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid g2">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Media provider abstraction</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Current provider: <span class="mono">mock</span> (<span class="mono">media/providers/mock.ts</span>).</li>
|
|
||||||
<li>Creates deterministic mock media session IDs.</li>
|
|
||||||
<li>Issues signed publish/subscribe tokens with TTL.</li>
|
|
||||||
<li>Uses <span class="mono">BETTER_AUTH_SECRET</span> for HMAC signing.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>SFU mode status</h3>
|
|
||||||
<ul>
|
|
||||||
<li><span class="mono">MEDIA_MODE=single_server_sfu</span> enables SFU endpoints.</li>
|
|
||||||
<li>Current implementation is a <strong>noop scaffold</strong> with in-memory session registry + synthetic transport IDs.</li>
|
|
||||||
<li>No full server-side RTP forwarding pipeline implemented yet.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="data">
|
|
||||||
<h2>7) Data Model (Core Tables and Relationships)</h2>
|
|
||||||
<div class="svg-box">
|
|
||||||
<svg viewBox="0 0 1280 880" role="img" aria-label="ER-style architecture data diagram">
|
|
||||||
<defs>
|
|
||||||
<marker id="arr3" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
|
||||||
<polygon points="0 0, 8 4, 0 8" fill="#8bcbe3" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect x="30" y="30" width="260" height="170" rx="10" fill="#132833" stroke="#2f6074"/>
|
|
||||||
<text x="48" y="58" fill="#ddf6ff" font-size="17" font-weight="700">users</text>
|
|
||||||
<text x="48" y="82" fill="#a5c9da" font-size="13">id (PK), email, name</text>
|
|
||||||
<text x="48" y="102" fill="#a5c9da" font-size="13">passwordHash, emailVerified</text>
|
|
||||||
<text x="48" y="122" fill="#a5c9da" font-size="13">createdAt, updatedAt</text>
|
|
||||||
|
|
||||||
<rect x="340" y="30" width="290" height="210" rx="10" fill="#132833" stroke="#2f6074"/>
|
|
||||||
<text x="358" y="58" fill="#ddf6ff" font-size="17" font-weight="700">devices</text>
|
|
||||||
<text x="358" y="82" fill="#a5c9da" font-size="13">id (PK), userId (FK -> users)</text>
|
|
||||||
<text x="358" y="102" fill="#a5c9da" font-size="13">role, status, isCamera</text>
|
|
||||||
<text x="358" y="122" fill="#a5c9da" font-size="13">platform, appVersion, pushToken</text>
|
|
||||||
<text x="358" y="142" fill="#a5c9da" font-size="13">lastSeenAt, timestamps</text>
|
|
||||||
|
|
||||||
<rect x="680" y="30" width="300" height="190" rx="10" fill="#153329" stroke="#3f7351"/>
|
|
||||||
<text x="698" y="58" fill="#e4ffea" font-size="17" font-weight="700">device_links</text>
|
|
||||||
<text x="698" y="82" fill="#b8dfc4" font-size="13">ownerUserId -> users</text>
|
|
||||||
<text x="698" y="102" fill="#b8dfc4" font-size="13">cameraDeviceId -> devices</text>
|
|
||||||
<text x="698" y="122" fill="#b8dfc4" font-size="13">clientDeviceId -> devices</text>
|
|
||||||
<text x="698" y="142" fill="#b8dfc4" font-size="13">unique(cameraDeviceId, clientDeviceId)</text>
|
|
||||||
|
|
||||||
<rect x="1020" y="30" width="230" height="210" rx="10" fill="#2f271d" stroke="#7a5c2f"/>
|
|
||||||
<text x="1038" y="58" fill="#fff1da" font-size="17" font-weight="700">device_commands</text>
|
|
||||||
<text x="1038" y="82" fill="#f0d7ac" font-size="13">sourceDeviceId -> devices</text>
|
|
||||||
<text x="1038" y="102" fill="#f0d7ac" font-size="13">targetDeviceId -> devices</text>
|
|
||||||
<text x="1038" y="122" fill="#f0d7ac" font-size="13">commandType, payload</text>
|
|
||||||
<text x="1038" y="142" fill="#f0d7ac" font-size="13">status, retryCount, ackAt</text>
|
|
||||||
|
|
||||||
<rect x="340" y="300" width="300" height="210" rx="10" fill="#132833" stroke="#2f6074"/>
|
|
||||||
<text x="358" y="328" fill="#ddf6ff" font-size="17" font-weight="700">stream_sessions</text>
|
|
||||||
<text x="358" y="352" fill="#a5c9da" font-size="13">ownerUserId -> users</text>
|
|
||||||
<text x="358" y="372" fill="#a5c9da" font-size="13">cameraDeviceId -> devices</text>
|
|
||||||
<text x="358" y="392" fill="#a5c9da" font-size="13">requesterDeviceId -> devices</text>
|
|
||||||
<text x="358" y="412" fill="#a5c9da" font-size="13">status, reason, mediaProvider</text>
|
|
||||||
<text x="358" y="432" fill="#a5c9da" font-size="13">mediaSessionId, streamKey</text>
|
|
||||||
|
|
||||||
<rect x="680" y="300" width="300" height="210" rx="10" fill="#153329" stroke="#3f7351"/>
|
|
||||||
<text x="698" y="328" fill="#e4ffea" font-size="17" font-weight="700">recordings</text>
|
|
||||||
<text x="698" y="352" fill="#b8dfc4" font-size="13">streamSessionId -> stream_sessions</text>
|
|
||||||
<text x="698" y="372" fill="#b8dfc4" font-size="13">cameraDeviceId, requesterDeviceId</text>
|
|
||||||
<text x="698" y="392" fill="#b8dfc4" font-size="13">status awaiting_upload/ready/failed</text>
|
|
||||||
<text x="698" y="412" fill="#b8dfc4" font-size="13">objectKey, bucket, duration, size</text>
|
|
||||||
|
|
||||||
<rect x="1020" y="300" width="230" height="190" rx="10" fill="#2f271d" stroke="#7a5c2f"/>
|
|
||||||
<text x="1038" y="328" fill="#fff1da" font-size="17" font-weight="700">events</text>
|
|
||||||
<text x="1038" y="352" fill="#f0d7ac" font-size="13">userId -> users</text>
|
|
||||||
<text x="1038" y="372" fill="#f0d7ac" font-size="13">deviceId -> devices</text>
|
|
||||||
<text x="1038" y="392" fill="#f0d7ac" font-size="13">startedAt, endedAt, status</text>
|
|
||||||
<text x="1038" y="412" fill="#f0d7ac" font-size="13">triggeredBy, videoUrl</text>
|
|
||||||
|
|
||||||
<rect x="30" y="560" width="300" height="220" rx="10" fill="#132833" stroke="#2f6074"/>
|
|
||||||
<text x="48" y="588" fill="#ddf6ff" font-size="17" font-weight="700">videos (legacy upload metadata)</text>
|
|
||||||
<text x="48" y="612" fill="#a5c9da" font-size="13">userId, deviceId, eventId</text>
|
|
||||||
<text x="48" y="632" fill="#a5c9da" font-size="13">objectKey, bucket, uploadUrl</text>
|
|
||||||
<text x="48" y="652" fill="#a5c9da" font-size="13">downloadUrl, status, expiresAt</text>
|
|
||||||
|
|
||||||
<rect x="370" y="560" width="300" height="220" rx="10" fill="#153329" stroke="#3f7351"/>
|
|
||||||
<text x="388" y="588" fill="#e4ffea" font-size="17" font-weight="700">push_notifications</text>
|
|
||||||
<text x="388" y="612" fill="#b8dfc4" font-size="13">ownerUserId -> users</text>
|
|
||||||
<text x="388" y="632" fill="#b8dfc4" font-size="13">recipientDeviceId -> devices</text>
|
|
||||||
<text x="388" y="652" fill="#b8dfc4" font-size="13">type, payload, status</text>
|
|
||||||
<text x="388" y="672" fill="#b8dfc4" font-size="13">attempts, nextAttemptAt, sentAt</text>
|
|
||||||
|
|
||||||
<rect x="710" y="560" width="270" height="220" rx="10" fill="#2f271d" stroke="#7a5c2f"/>
|
|
||||||
<text x="728" y="588" fill="#fff1da" font-size="17" font-weight="700">audit_logs</text>
|
|
||||||
<text x="728" y="612" fill="#f0d7ac" font-size="13">ownerUserId -> users</text>
|
|
||||||
<text x="728" y="632" fill="#f0d7ac" font-size="13">actorDeviceId -> devices</text>
|
|
||||||
<text x="728" y="652" fill="#f0d7ac" font-size="13">action, targetType, targetId</text>
|
|
||||||
<text x="728" y="672" fill="#f0d7ac" font-size="13">metadata, ipAddress, createdAt</text>
|
|
||||||
|
|
||||||
<rect x="1020" y="560" width="230" height="220" rx="10" fill="#1f2d36" stroke="#4a6f80"/>
|
|
||||||
<text x="1038" y="588" fill="#daf4ff" font-size="17" font-weight="700">Better Auth tables</text>
|
|
||||||
<text x="1038" y="612" fill="#a9cfdf" font-size="13">account</text>
|
|
||||||
<text x="1038" y="632" fill="#a9cfdf" font-size="13">session</text>
|
|
||||||
<text x="1038" y="652" fill="#a9cfdf" font-size="13">verification</text>
|
|
||||||
|
|
||||||
<line x1="290" y1="96" x2="340" y2="96" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
<line x1="290" y1="130" x2="680" y2="130" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
<line x1="630" y1="140" x2="1020" y2="140" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
<line x1="490" y1="240" x2="490" y2="300" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
<line x1="640" y1="404" x2="680" y2="404" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
<line x1="1135" y1="240" x2="1135" y2="300" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
<line x1="190" y1="200" x2="190" y2="560" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
<line x1="490" y1="510" x2="520" y2="560" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
<line x1="490" y1="510" x2="845" y2="560" stroke="#8bcbe3" stroke-width="2" marker-end="url(#arr3)"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="small">Note: <span class="mono">notifications</span> table exists for event notification tracking; push delivery queue is modeled separately by <span class="mono">push_notifications</span>.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="routes">
|
|
||||||
<h2>8) Route Surface and Responsibilities</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Area</th>
|
|
||||||
<th>Auth</th>
|
|
||||||
<th>Main tables/resources</th>
|
|
||||||
<th>Side effects</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/api/auth/*</span></td>
|
|
||||||
<td>Better Auth</td>
|
|
||||||
<td>users, account, session, verification</td>
|
|
||||||
<td>session cookie lifecycle</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/devices</span></td>
|
|
||||||
<td>session + device token for heartbeat</td>
|
|
||||||
<td>devices, device_links</td>
|
|
||||||
<td>auto-link opposite-role devices on register; stale-status projection on list</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/device-links</span></td>
|
|
||||||
<td>session</td>
|
|
||||||
<td>device_links, devices</td>
|
|
||||||
<td>enforces camera/client role pairing</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/commands</span></td>
|
|
||||||
<td>session + device token ack fallback</td>
|
|
||||||
<td>device_commands, devices, device_links</td>
|
|
||||||
<td>dispatch to Socket.IO, ack/reject propagation</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/events</span></td>
|
|
||||||
<td>device token (start/end), session (list)</td>
|
|
||||||
<td>events, device_links, notifications</td>
|
|
||||||
<td>realtime motion fanout, push fallback, audit log</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/streams</span></td>
|
|
||||||
<td>device token</td>
|
|
||||||
<td>stream_sessions, device_commands, recordings</td>
|
|
||||||
<td>stream command dispatch, media credentials, stream realtime events, push fallback, optional SFU calls</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/recordings</span></td>
|
|
||||||
<td>device token</td>
|
|
||||||
<td>recordings</td>
|
|
||||||
<td>storage object validation, presigned download URL, audit log</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/videos</span></td>
|
|
||||||
<td>session</td>
|
|
||||||
<td>videos, devices, MinIO</td>
|
|
||||||
<td>presigned PUT/GET generation, object listing/deletion</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/push-notifications</span></td>
|
|
||||||
<td>device token</td>
|
|
||||||
<td>push_notifications</td>
|
|
||||||
<td>manual worker dispatch trigger</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/audit</span></td>
|
|
||||||
<td>device token</td>
|
|
||||||
<td>audit_logs</td>
|
|
||||||
<td>none (read only)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/ops</span></td>
|
|
||||||
<td>none</td>
|
|
||||||
<td>DB, MinIO, in-memory metrics, SFU service</td>
|
|
||||||
<td>readiness checks, metric export</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><span class="mono">/admin</span></td>
|
|
||||||
<td>HTTP Basic auth</td>
|
|
||||||
<td>MinIO</td>
|
|
||||||
<td>embedded admin UI + object operations</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<h3>Detailed endpoint groups</h3>
|
|
||||||
<details>
|
|
||||||
<summary>Devices and Links</summary>
|
|
||||||
<ul>
|
|
||||||
<li><span class="mono">POST /devices/register</span>: creates device, sets initial online status, auto-creates links with existing opposite-role devices, returns device token.</li>
|
|
||||||
<li><span class="mono">GET /devices</span>: lists user devices with computed effective presence status using <span class="mono">DEVICE_ONLINE_STALE_SECONDS</span>.</li>
|
|
||||||
<li><span class="mono">PATCH /devices/:id</span>: updates mutable metadata and role.</li>
|
|
||||||
<li><span class="mono">POST /devices/:id/heartbeat</span>: token-authenticated presence update for exact device token/device match.</li>
|
|
||||||
<li><span class="mono">/device-links</span>: ensures one active camera-client pair and ownership checks.</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary>Commands, Events, Streams</summary>
|
|
||||||
<ul>
|
|
||||||
<li><span class="mono">POST /commands</span>: only client -> camera, only for active links.</li>
|
|
||||||
<li><span class="mono">POST /events/motion/start</span>: camera-only; sends realtime to linked clients, queues push if offline.</li>
|
|
||||||
<li><span class="mono">POST /streams/request</span>: creates stream session + start_stream command + realtime notification.</li>
|
|
||||||
<li><span class="mono">POST /streams/:id/accept</span>: camera transitions stream to streaming; creates media session and optional SFU bootstrap.</li>
|
|
||||||
<li><span class="mono">GET /streams/:id/publish-credentials</span>: camera-only credential issuance.</li>
|
|
||||||
<li><span class="mono">GET /streams/:id/subscribe-credentials</span>: participant credential issuance.</li>
|
|
||||||
<li><span class="mono">POST /streams/:id/end</span>: closes session, ends SFU (if enabled), creates recording placeholder, notifies both parties.</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
<details>
|
|
||||||
<summary>Storage and Recordings</summary>
|
|
||||||
<ul>
|
|
||||||
<li><span class="mono">POST /videos/upload-url</span>: session route to mint presigned PUT + metadata row.</li>
|
|
||||||
<li><span class="mono">POST /recordings/:id/finalize</span>: camera marks recording ready once object exists, or creates simulator placeholder if object key starts with <span class="mono">sim/</span>.</li>
|
|
||||||
<li><span class="mono">GET /recordings/:id/download-url</span>: requester/camera only, ready-only, verifies object exists before presigning.</li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="workers">
|
|
||||||
<h2>9) Workers and Reliability Mechanisms</h2>
|
|
||||||
<div class="grid g3">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Command retry loop</h3>
|
|
||||||
<p>Inside realtime gateway. Scans <span class="mono">device_commands</span> where status is <span class="mono">sent</span> and stale by >10s. Re-dispatches every 5s. Fails after 3 retries.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>Push worker</h3>
|
|
||||||
<p>Interval (default 10s) dispatches queued notifications with <span class="mono">nextAttemptAt <= now</span>. Missing push token triggers retry backoff; max attempts configurable.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>Recording worker</h3>
|
|
||||||
<p>Interval (default 30s) marks stale <span class="mono">awaiting_upload</span> recordings as <span class="mono">failed</span> after timeout window (default 30 min).</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="small">Workers perform startup guards using <span class="mono">hasRequiredTables()</span> so they do not run before migrations are applied.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="security">
|
|
||||||
<h2>10) Security Controls and Guardrails</h2>
|
|
||||||
<div class="grid g2">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Implemented</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Helmet CSP</strong> with explicit script/style/font/connect/media/image directives.</li>
|
|
||||||
<li><strong>CORS</strong> tied to <span class="mono">BETTER_AUTH_TRUSTED_ORIGINS</span> (or permissive fallback).</li>
|
|
||||||
<li><strong>Rate limiting</strong> globally and on high-traffic route groups.</li>
|
|
||||||
<li><strong>Ownership checks</strong> on almost all queries (user-scoped data access).</li>
|
|
||||||
<li><strong>Role constraints</strong> (for example client->camera command direction).</li>
|
|
||||||
<li><strong>Token integrity</strong> via timing-safe HMAC verification.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>Important caveats</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Rate limits are in-memory; not shared across replicas.</li>
|
|
||||||
<li>Metrics are in-memory counters only (no persistence/export protocol).</li>
|
|
||||||
<li>Mock push provider treats presence of push token as delivery success.</li>
|
|
||||||
<li>Mock media provider + SFU scaffold are not production media infrastructure.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="config">
|
|
||||||
<h2>11) Configuration Map (Key Env Variables)</h2>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Domain</th>
|
|
||||||
<th>Variables</th>
|
|
||||||
<th>Architectural effect</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Core server</td>
|
|
||||||
<td><span class="mono">PORT</span>, <span class="mono">DATABASE_URL</span></td>
|
|
||||||
<td>listener + DB connectivity</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Auth</td>
|
|
||||||
<td><span class="mono">BETTER_AUTH_SECRET</span>, <span class="mono">BETTER_AUTH_BASE_URL</span>, <span class="mono">BETTER_AUTH_TRUSTED_ORIGINS</span></td>
|
|
||||||
<td>session signing, base URL, trusted origins, device token signing</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Presence</td>
|
|
||||||
<td><span class="mono">DEVICE_ONLINE_STALE_SECONDS</span></td>
|
|
||||||
<td>effective online/offline projection in device listings</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Storage</td>
|
|
||||||
<td><span class="mono">MINIO_ENDPOINT</span>, <span class="mono">MINIO_PORT</span>, <span class="mono">MINIO_USE_SSL</span>, <span class="mono">MINIO_ACCESS_KEY</span>, <span class="mono">MINIO_SECRET_KEY</span>, <span class="mono">MINIO_BUCKET</span>, <span class="mono">MINIO_PRESIGNED_EXPIRY_SECONDS</span></td>
|
|
||||||
<td>object I/O, presign TTL, startup bucket bootstrap</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Media</td>
|
|
||||||
<td><span class="mono">MEDIA_MODE</span>, <span class="mono">MEDIA_PROVIDER</span>, <span class="mono">TURN_URLS</span>, <span class="mono">TURN_USERNAME</span>, <span class="mono">TURN_CREDENTIAL</span></td>
|
|
||||||
<td>control plane mode and transport descriptor generation</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Workers</td>
|
|
||||||
<td><span class="mono">PUSH_WORKER_INTERVAL_MS</span>, <span class="mono">PUSH_MAX_ATTEMPTS</span>, <span class="mono">RECORDING_WORKER_INTERVAL_MS</span>, <span class="mono">RECORDING_STALE_SECONDS</span></td>
|
|
||||||
<td>queue throughput, retry/failure timing</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Admin</td>
|
|
||||||
<td><span class="mono">ADMIN_USERNAME</span>, <span class="mono">ADMIN_PASSWORD</span></td>
|
|
||||||
<td>required to mount admin dashboard route logic</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="code-map">
|
|
||||||
<h2>12) Code Ownership Map (Where to Modify What)</h2>
|
|
||||||
<div class="grid g2">
|
|
||||||
<div class="card">
|
|
||||||
<h3>Server composition</h3>
|
|
||||||
<p><span class="mono">index.ts</span>: middleware stack, route mounting, startup ordering, workers, realtime setup.</p>
|
|
||||||
<h3>Identity</h3>
|
|
||||||
<p><span class="mono">auth.ts</span>, <span class="mono">middleware/auth.ts</span>, <span class="mono">middleware/device-auth.ts</span>, <span class="mono">utils/device-token.ts</span>.</p>
|
|
||||||
<h3>Persistence schema</h3>
|
|
||||||
<p><span class="mono">db/schema.ts</span> + <span class="mono">drizzle/*</span> migrations.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>Realtime + command delivery</h3>
|
|
||||||
<p><span class="mono">realtime/gateway.ts</span> and <span class="mono">routes/commands.ts</span>.</p>
|
|
||||||
<h3>Streaming control</h3>
|
|
||||||
<p><span class="mono">routes/streams.ts</span>, <span class="mono">media/*</span>, <span class="mono">routes/recordings.ts</span>.</p>
|
|
||||||
<h3>Operational views</h3>
|
|
||||||
<p><span class="mono">routes/ops.ts</span>, <span class="mono">observability/metrics.ts</span>, <span class="mono">routes/admin.ts</span>.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="constraints">
|
|
||||||
<h2>13) Current Constraints and Scaling Boundaries</h2>
|
|
||||||
<div class="grid g3">
|
|
||||||
<div class="card">
|
|
||||||
<h3>State locality</h3>
|
|
||||||
<p>Presence, rate-limit counters, metrics counters, and SFU registry are process-local. Horizontal scaling requires external shared state.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>Media realism</h3>
|
|
||||||
<p>Media provider is mock; SFU service is scaffold/noop. Production deployment needs real media infrastructure for reliability and scale.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<h3>Queue semantics</h3>
|
|
||||||
<p>Push and command retries are interval-based polling workers. Throughput, ordering guarantees, and dead-letter handling are minimal.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="small">For load-bearing evolution, the natural next architecture step is extracting shared state (Redis/queue), production media plane, and distributed rate/metrics telemetry.</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="black">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SecureCam Web Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.6.0/dist/full.min.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: rgba(15, 15, 20, 0.7);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(25, 25, 30, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: #10b981;
|
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: #ef4444;
|
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter {
|
|
||||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-page="activity" class="h-screen bg-[#0a0a0c] text-gray-200 overflow-hidden flex">
|
|
||||||
|
|
||||||
<!-- Toast Container -->
|
|
||||||
<div id="toast-container" class="toast toast-top toast-end z-50"></div>
|
|
||||||
|
|
||||||
<!-- MAIN APP SHELL -->
|
|
||||||
<div class="flex h-full w-full">
|
|
||||||
|
|
||||||
<!-- SIDEBAR NAVIGATION -->
|
|
||||||
<aside id="bottomNav"
|
|
||||||
class="w-20 lg:w-64 glass-panel border-r border-white/5 flex-col justify-between hidden h-full">
|
|
||||||
<!-- Logo Area -->
|
|
||||||
<div class="p-6 flex items-center justify-center lg:justify-start gap-3 border-b border-white/5">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav Links -->
|
|
||||||
<nav class="flex-1 py-6 px-3 space-y-2">
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="home">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all relative data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="activity">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
|
|
||||||
<span id="notificationDot"
|
|
||||||
class="absolute lg:relative lg:top-auto lg:right-auto top-3 right-3 lg:ml-auto w-2 h-2 bg-red-500 rounded-full hidden"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="settings">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
|
||||||
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Top Bar -->
|
|
||||||
<header
|
|
||||||
class="h-16 shrink-0 border-b border-white/5 flex items-center justify-end px-6 relative z-10 glass-panel">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div id="connectionStatus" class="flex items-center gap-2">
|
|
||||||
<span class="status-dot status-offline transition-colors duration-300"></span>
|
|
||||||
<span class="text-xs text-gray-400 font-medium tracking-wide uppercase">OFFLINE</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-white/10"></div>
|
|
||||||
|
|
||||||
<div id="authStatusBadge" class="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold border border-white/10">
|
|
||||||
?</div>
|
|
||||||
<span class="hidden sm:inline">Signed Out</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Pages -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ACTIVITY SCREEN -->
|
|
||||||
<section id="screen-activity" class="hidden flex-col gap-6 max-w-4xl mx-auto py-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-bold text-white tracking-tight">Activity History</h2>
|
|
||||||
<button id="clearActivityBtn"
|
|
||||||
class="btn btn-ghost text-gray-400 hover:bg-white/5 hover:text-white rounded-xl border border-transparent">Clear
|
|
||||||
Read</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 p-2 overflow-hidden">
|
|
||||||
<div id="activityFeedList" class="divide-y divide-white/5">
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div class="text-center py-16 opacity-50">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 text-gray-600" fill="none"
|
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<p class="text-sm font-medium text-gray-400">All quiet. No notifications yet.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div> <!-- End Main App Shell -->
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
|
||||||
<script src="/sim/mobile-sim.js" defer></script>
|
|
||||||
|
|
||||||
<!-- Recording Modal -->
|
|
||||||
<div id="recordingModal"
|
|
||||||
class="fixed inset-0 bg-[#0a0a0c]/90 backdrop-blur z-[100] hidden items-center justify-center p-4 lg:p-10">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-4xl glass-card rounded-3xl p-6 space-y-4 shadow-2xl border border-white/10 flex flex-col max-h-[90vh]">
|
|
||||||
<div class="flex items-center justify-between shrink-0">
|
|
||||||
<h3 id="recordingModalTitle" class="text-lg font-semibold text-white tracking-wide">Recording Playback</h3>
|
|
||||||
<button id="recordingModalCloseBtn"
|
|
||||||
class="btn btn-square btn-ghost text-gray-400 hover:text-white rounded-xl hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 bg-black rounded-2xl overflow-hidden relative border border-white/5 shadow-inner">
|
|
||||||
<video id="recordingModalVideo" class="w-full h-full object-contain" controls playsinline></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="black">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SecureCam Web Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.6.0/dist/full.min.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: rgba(15, 15, 20, 0.7);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(25, 25, 30, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: #10b981;
|
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: #ef4444;
|
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter {
|
|
||||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-page="auth" class="h-screen bg-[#0a0a0c] text-gray-200 overflow-hidden flex">
|
|
||||||
|
|
||||||
<!-- Toast Container -->
|
|
||||||
<div id="toast-container" class="toast toast-top toast-end z-50"></div>
|
|
||||||
|
|
||||||
<!-- MAIN APP SHELL -->
|
|
||||||
<div class="flex h-full w-full">
|
|
||||||
|
|
||||||
<!-- SIDEBAR NAVIGATION -->
|
|
||||||
<aside id="bottomNav"
|
|
||||||
class="w-20 lg:w-64 glass-panel border-r border-white/5 flex-col justify-between hidden h-full">
|
|
||||||
<!-- Logo Area -->
|
|
||||||
<div class="p-6 flex items-center justify-center lg:justify-start gap-3 border-b border-white/5">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav Links -->
|
|
||||||
<nav class="flex-1 py-6 px-3 space-y-2">
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="home">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all relative data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="activity">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
|
|
||||||
<span id="notificationDot"
|
|
||||||
class="absolute lg:relative lg:top-auto lg:right-auto top-3 right-3 lg:ml-auto w-2 h-2 bg-red-500 rounded-full hidden"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="settings">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
|
||||||
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Top Bar -->
|
|
||||||
<header
|
|
||||||
class="h-16 shrink-0 border-b border-white/5 flex items-center justify-end px-6 relative z-10 glass-panel">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div id="connectionStatus" class="flex items-center gap-2">
|
|
||||||
<span class="status-dot status-offline transition-colors duration-300"></span>
|
|
||||||
<span class="text-xs text-gray-400 font-medium tracking-wide uppercase">OFFLINE</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-white/10"></div>
|
|
||||||
|
|
||||||
<div id="authStatusBadge" class="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold border border-white/10">
|
|
||||||
?</div>
|
|
||||||
<span class="hidden sm:inline">Signed Out</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Pages -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
|
||||||
|
|
||||||
<!-- AUTH SCREEN -->
|
|
||||||
<section id="screen-auth"
|
|
||||||
class="flex flex-col items-center justify-center min-h-[70vh] animate-fade-in max-w-sm mx-auto">
|
|
||||||
<div class="text-center space-y-3 mb-8">
|
|
||||||
<div
|
|
||||||
class="w-20 h-20 bg-gradient-to-tr from-blue-600 to-indigo-600 rounded-3xl mx-auto flex items-center justify-center shadow-lg shadow-blue-900/20">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-3xl font-bold text-white tracking-tight">SecureCam Web</h2>
|
|
||||||
<p class="text-gray-400 text-sm">Sign in to manage visual security from your browser.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full glass-card p-6 md:p-8 rounded-3xl space-y-4 shadow-2xl">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label hidden"><span class="label-text text-gray-400">Email</span></label>
|
|
||||||
<input id="authEmail" type="email" placeholder="Email address"
|
|
||||||
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<input id="authPassword" type="password" placeholder="Password"
|
|
||||||
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
<div id="authNameField" class="form-control hidden">
|
|
||||||
<input id="authName" type="text" placeholder="Your Name"
|
|
||||||
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4 space-y-4">
|
|
||||||
<button id="signInBtn"
|
|
||||||
class="btn btn-premium w-full h-12 rounded-xl shadow-lg shadow-blue-900/20 text-base">Sign In</button>
|
|
||||||
<div class="divider text-xs text-gray-600">OR</div>
|
|
||||||
<button id="toggleAuthModeBtn"
|
|
||||||
class="btn btn-ghost w-full text-gray-400 hover:text-white hover:bg-white/5 rounded-xl border border-white/5">Create
|
|
||||||
an account</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div> <!-- End Main App Shell -->
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
|
||||||
<script src="/sim/mobile-sim.js" defer></script>
|
|
||||||
|
|
||||||
<!-- Recording Modal -->
|
|
||||||
<div id="recordingModal"
|
|
||||||
class="fixed inset-0 bg-[#0a0a0c]/90 backdrop-blur z-[100] hidden items-center justify-center p-4 lg:p-10">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-4xl glass-card rounded-3xl p-6 space-y-4 shadow-2xl border border-white/10 flex flex-col max-h-[90vh]">
|
|
||||||
<div class="flex items-center justify-between shrink-0">
|
|
||||||
<h3 id="recordingModalTitle" class="text-lg font-semibold text-white tracking-wide">Recording Playback</h3>
|
|
||||||
<button id="recordingModalCloseBtn"
|
|
||||||
class="btn btn-square btn-ghost text-gray-400 hover:text-white rounded-xl hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 bg-black rounded-2xl overflow-hidden relative border border-white/5 shadow-inner">
|
|
||||||
<video id="recordingModalVideo" class="w-full h-full object-contain" controls playsinline></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="black">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SecureCam Web Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.6.0/dist/full.min.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: rgba(15, 15, 20, 0.7);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(25, 25, 30, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: #10b981;
|
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: #ef4444;
|
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter {
|
|
||||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-page="camera" class="h-screen bg-[#0a0a0c] text-gray-200 overflow-hidden flex">
|
|
||||||
|
|
||||||
<!-- Toast Container -->
|
|
||||||
<div id="toast-container" class="toast toast-top toast-end z-50"></div>
|
|
||||||
|
|
||||||
<!-- MAIN APP SHELL -->
|
|
||||||
<div class="flex h-full w-full">
|
|
||||||
|
|
||||||
<!-- SIDEBAR NAVIGATION -->
|
|
||||||
<aside id="bottomNav"
|
|
||||||
class="w-20 lg:w-64 glass-panel border-r border-white/5 flex-col justify-between hidden h-full">
|
|
||||||
<!-- Logo Area -->
|
|
||||||
<div class="p-6 flex items-center justify-center lg:justify-start gap-3 border-b border-white/5">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav Links -->
|
|
||||||
<nav class="flex-1 py-6 px-3 space-y-2">
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="home">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all relative data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="activity">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
|
|
||||||
<span id="notificationDot"
|
|
||||||
class="absolute lg:relative lg:top-auto lg:right-auto top-3 right-3 lg:ml-auto w-2 h-2 bg-red-500 rounded-full hidden"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="settings">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
|
||||||
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Top Bar -->
|
|
||||||
<header
|
|
||||||
class="h-16 shrink-0 border-b border-white/5 flex items-center justify-end px-6 relative z-10 glass-panel">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div id="connectionStatus" class="flex items-center gap-2">
|
|
||||||
<span class="status-dot status-offline transition-colors duration-300"></span>
|
|
||||||
<span class="text-xs text-gray-400 font-medium tracking-wide uppercase">OFFLINE</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-white/10"></div>
|
|
||||||
|
|
||||||
<div id="authStatusBadge" class="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold border border-white/10">
|
|
||||||
?</div>
|
|
||||||
<span class="hidden sm:inline">Signed Out</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Pages -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- CAMERA DASHBOARD -->
|
|
||||||
<section id="screen-home-camera" class="hidden flex-col gap-10 max-w-7xl mx-auto h-full">
|
|
||||||
<div class="flex justify-between items-center shrink-0 mb-4">
|
|
||||||
<h2 class="text-2xl font-bold text-white tracking-tight">Camera Feed (Broadcasting)</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
|
||||||
<!-- Main Player -->
|
|
||||||
<div class="flex-1 glass-card rounded-3xl overflow-hidden relative flex flex-col border border-white/10">
|
|
||||||
<div id="cameraPreview" class="flex-1 bg-black relative flex items-center justify-center">
|
|
||||||
<video id="cameraVideo" class="absolute inset-0 w-full h-full object-cover hidden" autoplay playsinline
|
|
||||||
muted></video>
|
|
||||||
<!-- Recording indicator -->
|
|
||||||
<div
|
|
||||||
class="absolute top-4 left-4 z-20 flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/50 backdrop-blur border border-white/10">
|
|
||||||
<span
|
|
||||||
class="w-2.5 h-2.5 bg-red-500 rounded-full shadow-[0_0_8px_rgba(239,68,68,0.8)] animate-pulse"></span>
|
|
||||||
<span class="text-xs text-white font-medium tracking-wide">REC</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="cameraOfflineOverlay"
|
|
||||||
class="absolute inset-0 bg-[#0a0a0c]/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center gap-4">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-gray-600" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-400 font-medium tracking-wide">Camera Offline</p>
|
|
||||||
<button id="cameraGoOnlineBtn"
|
|
||||||
class="btn btn-outline btn-success rounded-xl border-green-500/50 text-green-400 hover:bg-green-500/10 hover:border-green-400">
|
|
||||||
Go Online
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls Sidebar -->
|
|
||||||
<div class="lg:w-80 shrink-0 flex flex-col gap-6">
|
|
||||||
<div class="glass-card p-6 rounded-3xl border border-white/5 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider">Manual Controls</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<button id="startMotionBtn"
|
|
||||||
class="btn w-full justify-start h-14 rounded-xl bg-white/5 border-white/5 hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-all font-medium group">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 group-hover:text-red-400 mr-2"
|
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
Simulate Motion Event
|
|
||||||
</button>
|
|
||||||
<button id="endMotionBtn"
|
|
||||||
class="btn w-full justify-start h-14 rounded-xl bg-white/5 border-white/5 hover:bg-white/10 font-medium disabled:opacity-30"
|
|
||||||
disabled>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 mr-2" fill="none"
|
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
|
||||||
</svg>
|
|
||||||
Stop Recording
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass-card p-6 rounded-3xl border border-white/5 h-80 flex flex-col min-h-0 overflow-hidden">
|
|
||||||
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4 shrink-0">System Logs</h3>
|
|
||||||
<div id="cameraLogs"
|
|
||||||
class="flex-1 min-h-0 bg-black/40 rounded-xl p-4 text-xs font-mono text-gray-400 overflow-y-auto border border-white/5">
|
|
||||||
<div class="text-gray-600 italic">Awaiting connection...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div> <!-- End Main App Shell -->
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
|
||||||
<script src="/sim/mobile-sim.js" defer></script>
|
|
||||||
|
|
||||||
<!-- Recording Modal -->
|
|
||||||
<div id="recordingModal"
|
|
||||||
class="fixed inset-0 bg-[#0a0a0c]/90 backdrop-blur z-[100] hidden items-center justify-center p-4 lg:p-10">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-4xl glass-card rounded-3xl p-6 space-y-4 shadow-2xl border border-white/10 flex flex-col max-h-[90vh]">
|
|
||||||
<div class="flex items-center justify-between shrink-0">
|
|
||||||
<h3 id="recordingModalTitle" class="text-lg font-semibold text-white tracking-wide">Recording Playback</h3>
|
|
||||||
<button id="recordingModalCloseBtn"
|
|
||||||
class="btn btn-square btn-ghost text-gray-400 hover:text-white rounded-xl hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 bg-black rounded-2xl overflow-hidden relative border border-white/5 shadow-inner">
|
|
||||||
<video id="recordingModalVideo" class="w-full h-full object-contain" controls playsinline></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,335 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="black">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SecureCam Web Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.6.0/dist/full.min.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: rgba(15, 15, 20, 0.7);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(25, 25, 30, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: #10b981;
|
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: #ef4444;
|
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter {
|
|
||||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-page="client" class="h-screen bg-[#0a0a0c] text-gray-200 overflow-hidden flex">
|
|
||||||
|
|
||||||
<!-- Toast Container -->
|
|
||||||
<div id="toast-container" class="toast toast-top toast-end z-50"></div>
|
|
||||||
|
|
||||||
<!-- MAIN APP SHELL -->
|
|
||||||
<div class="flex h-full w-full">
|
|
||||||
|
|
||||||
<!-- SIDEBAR NAVIGATION -->
|
|
||||||
<aside id="bottomNav"
|
|
||||||
class="w-20 lg:w-64 glass-panel border-r border-white/5 flex-col justify-between hidden h-full">
|
|
||||||
<!-- Logo Area -->
|
|
||||||
<div class="p-6 flex items-center justify-center lg:justify-start gap-3 border-b border-white/5">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav Links -->
|
|
||||||
<nav class="flex-1 py-6 px-3 space-y-2">
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="home">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all relative data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="activity">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
|
|
||||||
<span id="notificationDot"
|
|
||||||
class="absolute lg:relative lg:top-auto lg:right-auto top-3 right-3 lg:ml-auto w-2 h-2 bg-red-500 rounded-full hidden"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="settings">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
|
||||||
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Top Bar -->
|
|
||||||
<header
|
|
||||||
class="h-16 shrink-0 border-b border-white/5 flex items-center justify-end px-6 relative z-10 glass-panel">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div id="connectionStatus" class="flex items-center gap-2">
|
|
||||||
<span class="status-dot status-offline transition-colors duration-300"></span>
|
|
||||||
<span class="text-xs text-gray-400 font-medium tracking-wide uppercase">OFFLINE</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-white/10"></div>
|
|
||||||
|
|
||||||
<div id="authStatusBadge" class="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold border border-white/10">
|
|
||||||
?</div>
|
|
||||||
<span class="hidden sm:inline">Signed Out</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Pages -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- CLIENT DASHBOARD -->
|
|
||||||
<section id="screen-home-client" class="hidden flex-col gap-12 max-w-7xl mx-auto h-full">
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-2xl font-bold text-white tracking-tight">Client Dashboard</h2>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button id="linkCameraBtn"
|
|
||||||
class="btn btn-outline border-white/10 text-gray-300 hover:text-white hover:bg-white/5 rounded-xl gap-2 shadow-none">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Link Camera
|
|
||||||
</button>
|
|
||||||
<button id="refreshClientBtn"
|
|
||||||
class="btn btn-ghost btn-square rounded-xl bg-white/5 border border-white/5 hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cameras -> Horizontal List -->
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 p-5 shrink-0 mb-4">
|
|
||||||
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Your Cameras</h3>
|
|
||||||
<div id="linkedCamerasList" class="flex overflow-x-auto gap-4 pb-2 snap-x">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
<div class="w-full text-center py-6 bg-black/20 rounded-2xl border border-dashed border-white/10">
|
|
||||||
<p class="text-gray-500 text-sm">No cameras linked yet</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Theater Player Layout -->
|
|
||||||
<div class="flex flex-col xl:flex-row gap-8 flex-1 min-h-0">
|
|
||||||
|
|
||||||
<!-- Stream Viewer -->
|
|
||||||
<div id="clientStreamViewerWrapper"
|
|
||||||
class="flex-1 glass-card rounded-3xl overflow-hidden border border-white/10 flex flex-col shadow-xl min-h-[400px] hidden">
|
|
||||||
<div class="px-5 py-4 border-b border-white/5 bg-black/20 flex justify-between items-center">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-red-500 animate-[pulse_2s_ease-in-out_infinite] hidden"
|
|
||||||
id="clientLiveDot"></span>
|
|
||||||
<h3 class="font-medium text-white tracking-wide" id="clientStreamViewerTitle">Live Feed Viewer</h3>
|
|
||||||
</div>
|
|
||||||
<button id="closeStreamViewerBtn"
|
|
||||||
class="btn btn-ghost btn-sm btn-circle text-gray-400 hover:text-white">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="clientStreamContainer" class="flex-1 bg-black relative flex items-center justify-center">
|
|
||||||
<!-- This video element receives the WebRTC stream -->
|
|
||||||
<video id="clientStreamVideo" class="absolute inset-0 w-full h-full object-contain hidden"
|
|
||||||
playsinline></video>
|
|
||||||
<img id="clientStreamImage" class="absolute inset-0 w-full h-full object-contain hidden" />
|
|
||||||
|
|
||||||
<div id="clientStreamPlaceholder" class="flex flex-col items-center gap-4 animate-pulse">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-700" fill="none"
|
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-sm font-medium text-gray-500 tracking-wide uppercase">Select a camera to view</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Sidebar (Recordings) -->
|
|
||||||
<div class="xl:w-96 shrink-0 flex flex-col gap-6 overflow-y-auto pr-2">
|
|
||||||
|
|
||||||
<!-- Recordings -->
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 p-5 flex-1">
|
|
||||||
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Recent Recordings</h3>
|
|
||||||
<div id="recordingsList" class="space-y-3">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div> <!-- End Main App Shell -->
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
|
||||||
<script src="/sim/mobile-sim.js" defer></script>
|
|
||||||
|
|
||||||
<!-- Recording Modal -->
|
|
||||||
<div id="recordingModal"
|
|
||||||
class="fixed inset-0 bg-[#0a0a0c]/90 backdrop-blur z-[100] hidden items-center justify-center p-4 lg:p-10">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-4xl glass-card rounded-3xl p-6 space-y-4 shadow-2xl border border-white/10 flex flex-col max-h-[90vh]">
|
|
||||||
<div class="flex items-center justify-between shrink-0">
|
|
||||||
<h3 id="recordingModalTitle" class="text-lg font-semibold text-white tracking-wide">Recording Playback</h3>
|
|
||||||
<button id="recordingModalCloseBtn"
|
|
||||||
class="btn btn-square btn-ghost text-gray-400 hover:text-white rounded-xl hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 bg-black rounded-2xl overflow-hidden relative border border-white/5 shadow-inner">
|
|
||||||
<video id="recordingModalVideo" class="w-full h-full object-contain" controls playsinline></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="black">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SecureCam Web Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.6.0/dist/full.min.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: rgba(15, 15, 20, 0.7);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(25, 25, 30, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: #10b981;
|
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: #ef4444;
|
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter {
|
|
||||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-page="onboarding" class="h-screen bg-[#0a0a0c] text-gray-200 overflow-hidden flex">
|
|
||||||
|
|
||||||
<!-- Toast Container -->
|
|
||||||
<div id="toast-container" class="toast toast-top toast-end z-50"></div>
|
|
||||||
|
|
||||||
<!-- MAIN APP SHELL -->
|
|
||||||
<div class="flex h-full w-full">
|
|
||||||
|
|
||||||
<!-- SIDEBAR NAVIGATION -->
|
|
||||||
<aside id="bottomNav"
|
|
||||||
class="w-20 lg:w-64 glass-panel border-r border-white/5 flex-col justify-between hidden h-full">
|
|
||||||
<!-- Logo Area -->
|
|
||||||
<div class="p-6 flex items-center justify-center lg:justify-start gap-3 border-b border-white/5">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav Links -->
|
|
||||||
<nav class="flex-1 py-6 px-3 space-y-2">
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="home">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all relative data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="activity">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
|
|
||||||
<span id="notificationDot"
|
|
||||||
class="absolute lg:relative lg:top-auto lg:right-auto top-3 right-3 lg:ml-auto w-2 h-2 bg-red-500 rounded-full hidden"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="settings">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
|
||||||
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Top Bar -->
|
|
||||||
<header
|
|
||||||
class="h-16 shrink-0 border-b border-white/5 flex items-center justify-end px-6 relative z-10 glass-panel">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div id="connectionStatus" class="flex items-center gap-2">
|
|
||||||
<span class="status-dot status-offline transition-colors duration-300"></span>
|
|
||||||
<span class="text-xs text-gray-400 font-medium tracking-wide uppercase">OFFLINE</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-white/10"></div>
|
|
||||||
|
|
||||||
<div id="authStatusBadge" class="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold border border-white/10">
|
|
||||||
?</div>
|
|
||||||
<span class="hidden sm:inline">Signed Out</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Pages -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ONBOARDING SCREEN -->
|
|
||||||
<section id="screen-onboarding"
|
|
||||||
class="hidden flex-col items-center justify-center min-h-[70vh] max-w-lg mx-auto">
|
|
||||||
<div class="text-center space-y-2 mb-8">
|
|
||||||
<h2 class="text-3xl font-bold text-white tracking-tight">Configure Device</h2>
|
|
||||||
<p class="text-sm text-gray-400">Set up this browser simulator's role</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full glass-card p-6 md:p-8 rounded-3xl space-y-6 shadow-2xl">
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label"><span class="label-text text-xs font-semibold text-gray-400 tracking-wider">DEVICE
|
|
||||||
NAME</span></label>
|
|
||||||
<input id="deviceName" type="text" placeholder="e.g. Living Room Cam"
|
|
||||||
class="input input-bordered h-12 rounded-xl bg-black/40 border-white/10 focus:border-blue-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label"><span
|
|
||||||
class="label-text text-xs font-semibold text-gray-400 tracking-wider">ROLE</span></label>
|
|
||||||
<div class="grid grid-cols-2 gap-2 p-1.5 bg-black/40 rounded-xl border border-white/5">
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost normal-case text-gray-400 data-[active=true]:bg-blue-600 data-[active=true]:text-white rounded-lg h-10 min-h-0"
|
|
||||||
id="btn-role-camera" data-role="camera" data-active="false">Camera</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost normal-case text-gray-400 data-[active=true]:bg-blue-600 data-[active=true]:text-white rounded-lg h-10 min-h-0"
|
|
||||||
id="btn-role-client" data-role="client" data-active="true">Client</button>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="role" value="client">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label"><span class="label-text text-xs font-semibold text-gray-400 tracking-wider">PUSH
|
|
||||||
TOKEN (OPTIONAL)</span></label>
|
|
||||||
<input id="pushToken" type="text" placeholder="simulated_token_123"
|
|
||||||
class="input input-bordered h-12 rounded-xl bg-black/40 border-white/10 focus:border-blue-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-6">
|
|
||||||
<button id="registerBtn" class="btn btn-premium w-full h-12 rounded-xl text-base">Complete Setup</button>
|
|
||||||
<button id="loadSavedBtn"
|
|
||||||
class="btn btn-ghost w-full mt-3 text-gray-500 hover:text-white hover:bg-white/5">Load previously saved
|
|
||||||
device</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div> <!-- End Main App Shell -->
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
|
||||||
<script src="/sim/mobile-sim.js" defer></script>
|
|
||||||
|
|
||||||
<!-- Recording Modal -->
|
|
||||||
<div id="recordingModal"
|
|
||||||
class="fixed inset-0 bg-[#0a0a0c]/90 backdrop-blur z-[100] hidden items-center justify-center p-4 lg:p-10">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-4xl glass-card rounded-3xl p-6 space-y-4 shadow-2xl border border-white/10 flex flex-col max-h-[90vh]">
|
|
||||||
<div class="flex items-center justify-between shrink-0">
|
|
||||||
<h3 id="recordingModalTitle" class="text-lg font-semibold text-white tracking-wide">Recording Playback</h3>
|
|
||||||
<button id="recordingModalCloseBtn"
|
|
||||||
class="btn btn-square btn-ghost text-gray-400 hover:text-white rounded-xl hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 bg-black rounded-2xl overflow-hidden relative border border-white/5 shadow-inner">
|
|
||||||
<video id="recordingModalVideo" class="w-full h-full object-contain" controls playsinline></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="black">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SecureCam Web Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.6.0/dist/full.min.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: rgba(15, 15, 20, 0.7);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(25, 25, 30, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: #10b981;
|
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: #ef4444;
|
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter {
|
|
||||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body data-page="settings" class="h-screen bg-[#0a0a0c] text-gray-200 overflow-hidden flex">
|
|
||||||
|
|
||||||
<!-- Toast Container -->
|
|
||||||
<div id="toast-container" class="toast toast-top toast-end z-50"></div>
|
|
||||||
|
|
||||||
<!-- MAIN APP SHELL -->
|
|
||||||
<div class="flex h-full w-full">
|
|
||||||
|
|
||||||
<!-- SIDEBAR NAVIGATION -->
|
|
||||||
<aside id="bottomNav"
|
|
||||||
class="w-20 lg:w-64 glass-panel border-r border-white/5 flex-col justify-between hidden h-full">
|
|
||||||
<!-- Logo Area -->
|
|
||||||
<div class="p-6 flex items-center justify-center lg:justify-start gap-3 border-b border-white/5">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav Links -->
|
|
||||||
<nav class="flex-1 py-6 px-3 space-y-2">
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="home">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all relative data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="activity">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
|
|
||||||
<span id="notificationDot"
|
|
||||||
class="absolute lg:relative lg:top-auto lg:right-auto top-3 right-3 lg:ml-auto w-2 h-2 bg-red-500 rounded-full hidden"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="settings">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
|
||||||
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Top Bar -->
|
|
||||||
<header
|
|
||||||
class="h-16 shrink-0 border-b border-white/5 flex items-center justify-end px-6 relative z-10 glass-panel">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div id="connectionStatus" class="flex items-center gap-2">
|
|
||||||
<span class="status-dot status-offline transition-colors duration-300"></span>
|
|
||||||
<span class="text-xs text-gray-400 font-medium tracking-wide uppercase">OFFLINE</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-white/10"></div>
|
|
||||||
|
|
||||||
<div id="authStatusBadge" class="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold border border-white/10">
|
|
||||||
?</div>
|
|
||||||
<span class="hidden sm:inline">Signed Out</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Pages -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- SETTINGS SCREEN -->
|
|
||||||
<section id="screen-settings" class="hidden flex-col gap-6 max-w-2xl mx-auto py-8">
|
|
||||||
<h2 class="text-2xl font-bold text-white tracking-tight px-2">Settings</h2>
|
|
||||||
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 p-8 flex items-center gap-6">
|
|
||||||
<div
|
|
||||||
class="w-20 h-20 bg-blue-600/20 text-blue-500 rounded-full flex items-center justify-center leading-none text-2xl font-bold border border-blue-500/30"
|
|
||||||
id="profileInitials">U</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl text-white font-semibold tracking-wide" id="profileName">User</h3>
|
|
||||||
<p class="text-gray-400 mt-1" id="profileEmail">user@example.com</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 overflow-hidden">
|
|
||||||
<button id="checkOpsBtn"
|
|
||||||
class="w-full flex items-center justify-between p-5 text-left text-gray-300 hover:bg-white/5 transition-colors border-b border-white/5 group">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div
|
|
||||||
class="p-2 rounded-lg bg-white/5 group-hover:bg-blue-500/20 group-hover:text-blue-400 transition-colors">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="font-medium">Run Diagnostics</span>
|
|
||||||
</div>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-between p-5 text-left text-gray-300 hover:bg-white/5 transition-colors group">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div
|
|
||||||
class="p-2 rounded-lg bg-white/5 group-hover:bg-blue-500/20 group-hover:text-blue-400 transition-colors">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="font-medium">Device Information</span>
|
|
||||||
</div>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="signOutBtn"
|
|
||||||
class="btn btn-error btn-outline w-full rounded-2xl h-14 text-base font-medium gap-3 border-red-500/50 hover:bg-red-500/10 hover:border-red-500 mt-4">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div> <!-- End Main App Shell -->
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
|
||||||
<script src="/sim/mobile-sim.js" defer></script>
|
|
||||||
|
|
||||||
<!-- Recording Modal -->
|
|
||||||
<div id="recordingModal"
|
|
||||||
class="fixed inset-0 bg-[#0a0a0c]/90 backdrop-blur z-[100] hidden items-center justify-center p-4 lg:p-10">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-4xl glass-card rounded-3xl p-6 space-y-4 shadow-2xl border border-white/10 flex flex-col max-h-[90vh]">
|
|
||||||
<div class="flex items-center justify-between shrink-0">
|
|
||||||
<h3 id="recordingModalTitle" class="text-lg font-semibold text-white tracking-wide">Recording Playback</h3>
|
|
||||||
<button id="recordingModalCloseBtn"
|
|
||||||
class="btn btn-square btn-ghost text-gray-400 hover:text-white rounded-xl hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 bg-black rounded-2xl overflow-hidden relative border border-white/5 shadow-inner">
|
|
||||||
<video id="recordingModalVideo" class="w-full h-full object-contain" controls playsinline></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,584 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" data-theme="black">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>SecureCam Web Dashboard</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.6.0/dist/full.min.css" rel="stylesheet" type="text/css" />
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-panel {
|
|
||||||
background: rgba(15, 15, 20, 0.7);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-card {
|
|
||||||
background: rgba(25, 25, 30, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium {
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-premium:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
height: 8px;
|
|
||||||
width: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-online {
|
|
||||||
background-color: #10b981;
|
|
||||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-offline {
|
|
||||||
background-color: #ef4444;
|
|
||||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter {
|
|
||||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="h-screen bg-[#0a0a0c] text-gray-200 overflow-hidden flex">
|
|
||||||
|
|
||||||
<!-- Toast Container -->
|
|
||||||
<div id="toast-container" class="toast toast-top toast-end z-50"></div>
|
|
||||||
|
|
||||||
<!-- MAIN APP SHELL -->
|
|
||||||
<div class="flex h-full w-full">
|
|
||||||
|
|
||||||
<!-- SIDEBAR NAVIGATION -->
|
|
||||||
<aside id="bottomNav"
|
|
||||||
class="w-20 lg:w-64 glass-panel border-r border-white/5 flex-col justify-between hidden h-full">
|
|
||||||
<!-- Logo Area -->
|
|
||||||
<div class="p-6 flex items-center justify-center lg:justify-start gap-3 border-b border-white/5">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Nav Links -->
|
|
||||||
<nav class="flex-1 py-6 px-3 space-y-2">
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="home">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Dashboard</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all relative data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="activity">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Activity Feed</span>
|
|
||||||
<span id="notificationDot"
|
|
||||||
class="absolute lg:relative lg:top-auto lg:right-auto top-3 right-3 lg:ml-auto w-2 h-2 bg-red-500 rounded-full hidden"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="nav-btn w-full flex items-center gap-3 p-3 rounded-xl text-gray-400 hover:text-white hover:bg-white/5 transition-all data-[active=true]:text-white data-[active=true]:bg-blue-600/10 data-[active=true]:border data-[active=true]:border-blue-500/20 group"
|
|
||||||
data-target="settings">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium hidden lg:block text-sm">Settings</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- CONTENT AREA -->
|
|
||||||
<main class="flex-1 flex flex-col h-full overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- Top Bar -->
|
|
||||||
<header
|
|
||||||
class="h-16 shrink-0 border-b border-white/5 flex items-center justify-end px-6 relative z-10 glass-panel">
|
|
||||||
<div class="flex items-center gap-6">
|
|
||||||
<div id="connectionStatus" class="flex items-center gap-2">
|
|
||||||
<span class="status-dot status-offline transition-colors duration-300"></span>
|
|
||||||
<span class="text-xs text-gray-400 font-medium tracking-wide uppercase">OFFLINE</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-6 w-px bg-white/10"></div>
|
|
||||||
|
|
||||||
<div id="authStatusBadge" class="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 rounded-full bg-gray-800 flex items-center justify-center text-xs font-bold border border-white/10">
|
|
||||||
?</div>
|
|
||||||
<span class="hidden sm:inline">Signed Out</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Pages -->
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
|
|
||||||
|
|
||||||
<!-- AUTH SCREEN -->
|
|
||||||
<section id="screen-auth"
|
|
||||||
class="flex flex-col items-center justify-center min-h-[70vh] animate-fade-in max-w-sm mx-auto">
|
|
||||||
<div class="text-center space-y-3 mb-8">
|
|
||||||
<div
|
|
||||||
class="w-20 h-20 bg-gradient-to-tr from-blue-600 to-indigo-600 rounded-3xl mx-auto flex items-center justify-center shadow-lg shadow-blue-900/20">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-3xl font-bold text-white tracking-tight">SecureCam Web</h2>
|
|
||||||
<p class="text-gray-400 text-sm">Sign in to manage visual security from your browser.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full glass-card p-6 md:p-8 rounded-3xl space-y-4 shadow-2xl">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label hidden"><span class="label-text text-gray-400">Email</span></label>
|
|
||||||
<input id="authEmail" type="email" placeholder="Email address"
|
|
||||||
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<input id="authPassword" type="password" placeholder="Password"
|
|
||||||
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
<div id="authNameField" class="form-control hidden">
|
|
||||||
<input id="authName" type="text" placeholder="Your Name"
|
|
||||||
class="input bg-black/40 border-white/10 text-sm focus:border-blue-500 focus:outline-none transition-colors w-full h-12 rounded-xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4 space-y-4">
|
|
||||||
<button id="signInBtn"
|
|
||||||
class="btn btn-premium w-full h-12 rounded-xl shadow-lg shadow-blue-900/20 text-base">Sign In</button>
|
|
||||||
<div class="divider text-xs text-gray-600">OR</div>
|
|
||||||
<button id="toggleAuthModeBtn"
|
|
||||||
class="btn btn-ghost w-full text-gray-400 hover:text-white hover:bg-white/5 rounded-xl border border-white/5">Create
|
|
||||||
an account</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ONBOARDING SCREEN -->
|
|
||||||
<section id="screen-onboarding"
|
|
||||||
class="hidden flex-col items-center justify-center min-h-[70vh] max-w-lg mx-auto">
|
|
||||||
<div class="text-center space-y-2 mb-8">
|
|
||||||
<h2 class="text-3xl font-bold text-white tracking-tight">Configure Device</h2>
|
|
||||||
<p class="text-sm text-gray-400">Set up this browser simulator's role</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full glass-card p-6 md:p-8 rounded-3xl space-y-6 shadow-2xl">
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label"><span class="label-text text-xs font-semibold text-gray-400 tracking-wider">DEVICE
|
|
||||||
NAME</span></label>
|
|
||||||
<input id="deviceName" type="text" placeholder="e.g. Living Room Cam"
|
|
||||||
class="input input-bordered h-12 rounded-xl bg-black/40 border-white/10 focus:border-blue-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label"><span
|
|
||||||
class="label-text text-xs font-semibold text-gray-400 tracking-wider">ROLE</span></label>
|
|
||||||
<div class="grid grid-cols-2 gap-2 p-1.5 bg-black/40 rounded-xl border border-white/5">
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost normal-case text-gray-400 data-[active=true]:bg-blue-600 data-[active=true]:text-white rounded-lg h-10 min-h-0"
|
|
||||||
id="btn-role-camera" data-role="camera" data-active="false">Camera</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-ghost normal-case text-gray-400 data-[active=true]:bg-blue-600 data-[active=true]:text-white rounded-lg h-10 min-h-0"
|
|
||||||
id="btn-role-client" data-role="client" data-active="true">Client</button>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" id="role" value="client">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label"><span class="label-text text-xs font-semibold text-gray-400 tracking-wider">PUSH
|
|
||||||
TOKEN (OPTIONAL)</span></label>
|
|
||||||
<input id="pushToken" type="text" placeholder="simulated_token_123"
|
|
||||||
class="input input-bordered h-12 rounded-xl bg-black/40 border-white/10 focus:border-blue-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-6">
|
|
||||||
<button id="registerBtn" class="btn btn-premium w-full h-12 rounded-xl text-base">Complete Setup</button>
|
|
||||||
<button id="loadSavedBtn"
|
|
||||||
class="btn btn-ghost w-full mt-3 text-gray-500 hover:text-white hover:bg-white/5">Load previously saved
|
|
||||||
device</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CAMERA DASHBOARD -->
|
|
||||||
<section id="screen-home-camera" class="hidden flex-col gap-10 max-w-7xl mx-auto h-full">
|
|
||||||
<div class="flex justify-between items-center shrink-0 mb-4">
|
|
||||||
<h2 class="text-2xl font-bold text-white tracking-tight">Camera Feed (Broadcasting)</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
|
||||||
<!-- Main Player -->
|
|
||||||
<div class="flex-1 glass-card rounded-3xl overflow-hidden relative flex flex-col border border-white/10">
|
|
||||||
<div id="cameraPreview" class="flex-1 bg-black relative flex items-center justify-center">
|
|
||||||
<video id="cameraVideo" class="absolute inset-0 w-full h-full object-cover hidden" autoplay playsinline
|
|
||||||
muted></video>
|
|
||||||
<!-- Recording indicator -->
|
|
||||||
<div
|
|
||||||
class="absolute top-4 left-4 z-20 flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/50 backdrop-blur border border-white/10">
|
|
||||||
<span
|
|
||||||
class="w-2.5 h-2.5 bg-red-500 rounded-full shadow-[0_0_8px_rgba(239,68,68,0.8)] animate-pulse"></span>
|
|
||||||
<span class="text-xs text-white font-medium tracking-wide">REC</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="cameraOfflineOverlay"
|
|
||||||
class="absolute inset-0 bg-[#0a0a0c]/80 backdrop-blur-sm z-10 flex flex-col items-center justify-center gap-4">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-gray-600" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-400 font-medium tracking-wide">Camera Offline</p>
|
|
||||||
<button id="cameraGoOnlineBtn"
|
|
||||||
class="btn btn-outline btn-success rounded-xl border-green-500/50 text-green-400 hover:bg-green-500/10 hover:border-green-400">
|
|
||||||
Go Online
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls Sidebar -->
|
|
||||||
<div class="lg:w-80 shrink-0 flex flex-col gap-6">
|
|
||||||
<div class="glass-card p-6 rounded-3xl border border-white/5 space-y-4">
|
|
||||||
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider">Manual Controls</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<button id="startMotionBtn"
|
|
||||||
class="btn w-full justify-start h-14 rounded-xl bg-white/5 border-white/5 hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-all font-medium group">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 group-hover:text-red-400 mr-2"
|
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
Simulate Motion Event
|
|
||||||
</button>
|
|
||||||
<button id="endMotionBtn"
|
|
||||||
class="btn w-full justify-start h-14 rounded-xl bg-white/5 border-white/5 hover:bg-white/10 font-medium disabled:opacity-30"
|
|
||||||
disabled>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400 mr-2" fill="none"
|
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
|
||||||
</svg>
|
|
||||||
Stop Recording
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass-card p-6 rounded-3xl border border-white/5 h-80 flex flex-col min-h-0 overflow-hidden">
|
|
||||||
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-4 shrink-0">System Logs</h3>
|
|
||||||
<div id="cameraLogs"
|
|
||||||
class="flex-1 min-h-0 bg-black/40 rounded-xl p-4 text-xs font-mono text-gray-400 overflow-y-auto border border-white/5">
|
|
||||||
<div class="text-gray-600 italic">Awaiting connection...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CLIENT DASHBOARD -->
|
|
||||||
<section id="screen-home-client" class="hidden flex-col gap-12 max-w-7xl mx-auto h-full">
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-2xl font-bold text-white tracking-tight">Client Dashboard</h2>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button id="linkCameraBtn"
|
|
||||||
class="btn btn-outline border-white/10 text-gray-300 hover:text-white hover:bg-white/5 rounded-xl gap-2 shadow-none">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Link Camera
|
|
||||||
</button>
|
|
||||||
<button id="refreshClientBtn"
|
|
||||||
class="btn btn-ghost btn-square rounded-xl bg-white/5 border border-white/5 hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cameras -> Horizontal List -->
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 p-5 shrink-0 mb-4">
|
|
||||||
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Your Cameras</h3>
|
|
||||||
<div id="linkedCamerasList" class="flex overflow-x-auto gap-4 pb-2 snap-x">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
<div class="w-full text-center py-6 bg-black/20 rounded-2xl border border-dashed border-white/10">
|
|
||||||
<p class="text-gray-500 text-sm">No cameras linked yet</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Theater Player Layout -->
|
|
||||||
<div class="flex flex-col xl:flex-row gap-8 flex-1 min-h-0">
|
|
||||||
|
|
||||||
<!-- Stream Viewer -->
|
|
||||||
<div id="clientStreamViewerWrapper"
|
|
||||||
class="flex-1 glass-card rounded-3xl overflow-hidden border border-white/10 flex flex-col shadow-xl min-h-[400px] hidden">
|
|
||||||
<div class="px-5 py-4 border-b border-white/5 bg-black/20 flex justify-between items-center">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-red-500 animate-[pulse_2s_ease-in-out_infinite] hidden"
|
|
||||||
id="clientLiveDot"></span>
|
|
||||||
<h3 class="font-medium text-white tracking-wide" id="clientStreamViewerTitle">Live Feed Viewer</h3>
|
|
||||||
</div>
|
|
||||||
<button id="closeStreamViewerBtn"
|
|
||||||
class="btn btn-ghost btn-sm btn-circle text-gray-400 hover:text-white">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="clientStreamContainer" class="flex-1 bg-black relative flex items-center justify-center">
|
|
||||||
<!-- This video element receives the WebRTC stream -->
|
|
||||||
<video id="clientStreamVideo" class="absolute inset-0 w-full h-full object-contain hidden"
|
|
||||||
playsinline></video>
|
|
||||||
<img id="clientStreamImage" class="absolute inset-0 w-full h-full object-contain hidden" />
|
|
||||||
|
|
||||||
<div id="clientStreamPlaceholder" class="flex flex-col items-center gap-4 animate-pulse">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-700" fill="none"
|
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-sm font-medium text-gray-500 tracking-wide uppercase">Select a camera to view</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Sidebar (Recordings) -->
|
|
||||||
<div class="xl:w-96 shrink-0 flex flex-col gap-6 overflow-y-auto pr-2">
|
|
||||||
|
|
||||||
<!-- Recordings -->
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 p-5 flex-1">
|
|
||||||
<h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-4">Recent Recordings</h3>
|
|
||||||
<div id="recordingsList" class="space-y-3">
|
|
||||||
<!-- Populated by JS -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ACTIVITY SCREEN -->
|
|
||||||
<section id="screen-activity" class="hidden flex-col gap-6 max-w-4xl mx-auto py-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-bold text-white tracking-tight">Activity History</h2>
|
|
||||||
<button id="clearActivityBtn"
|
|
||||||
class="btn btn-ghost text-gray-400 hover:bg-white/5 hover:text-white rounded-xl border border-transparent">Clear
|
|
||||||
Read</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 p-2 overflow-hidden">
|
|
||||||
<div id="activityFeedList" class="divide-y divide-white/5">
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div class="text-center py-16 opacity-50">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 text-gray-600" fill="none"
|
|
||||||
viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
<p class="text-sm font-medium text-gray-400">All quiet. No notifications yet.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- SETTINGS SCREEN -->
|
|
||||||
<section id="screen-settings" class="hidden flex-col gap-6 max-w-2xl mx-auto py-8">
|
|
||||||
<h2 class="text-2xl font-bold text-white tracking-tight px-2">Settings</h2>
|
|
||||||
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 p-8 flex items-center gap-6">
|
|
||||||
<div
|
|
||||||
class="w-20 h-20 bg-blue-600/20 text-blue-500 rounded-full flex items-center justify-center leading-none text-2xl font-bold border border-blue-500/30"
|
|
||||||
id="profileInitials">U</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl text-white font-semibold tracking-wide" id="profileName">User</h3>
|
|
||||||
<p class="text-gray-400 mt-1" id="profileEmail">user@example.com</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="glass-card rounded-3xl border border-white/5 overflow-hidden">
|
|
||||||
<button id="checkOpsBtn"
|
|
||||||
class="w-full flex items-center justify-between p-5 text-left text-gray-300 hover:bg-white/5 transition-colors border-b border-white/5 group">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div
|
|
||||||
class="p-2 rounded-lg bg-white/5 group-hover:bg-blue-500/20 group-hover:text-blue-400 transition-colors">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="font-medium">Run Diagnostics</span>
|
|
||||||
</div>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-between p-5 text-left text-gray-300 hover:bg-white/5 transition-colors group">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div
|
|
||||||
class="p-2 rounded-lg bg-white/5 group-hover:bg-blue-500/20 group-hover:text-blue-400 transition-colors">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="font-medium">Device Information</span>
|
|
||||||
</div>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-gray-600" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="signOutBtn"
|
|
||||||
class="btn btn-error btn-outline w-full rounded-2xl h-14 text-base font-medium gap-3 border-red-500/50 hover:bg-red-500/10 hover:border-red-500 mt-4">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 11-6 0v-1m6 0H9" />
|
|
||||||
</svg>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div> <!-- End Main App Shell -->
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
|
||||||
<script src="/sim/mobile-sim.js" defer></script>
|
|
||||||
|
|
||||||
<!-- Recording Modal -->
|
|
||||||
<div id="recordingModal"
|
|
||||||
class="fixed inset-0 bg-[#0a0a0c]/90 backdrop-blur z-[100] hidden items-center justify-center p-4 lg:p-10">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-4xl glass-card rounded-3xl p-6 space-y-4 shadow-2xl border border-white/10 flex flex-col max-h-[90vh]">
|
|
||||||
<div class="flex items-center justify-between shrink-0">
|
|
||||||
<h3 id="recordingModalTitle" class="text-lg font-semibold text-white tracking-wide">Recording Playback</h3>
|
|
||||||
<button id="recordingModalCloseBtn"
|
|
||||||
class="btn btn-square btn-ghost text-gray-400 hover:text-white rounded-xl hover:bg-white/10">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-h-0 bg-black rounded-2xl overflow-hidden relative border border-white/5 shadow-inner">
|
|
||||||
<video id="recordingModalVideo" class="w-full h-full object-contain" controls playsinline></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# SecureCam backend base URL consumed by the mobile app.
|
|
||||||
# iOS simulator: http://localhost:3000
|
|
||||||
# Android emulator: http://10.0.2.2:3000
|
|
||||||
# Physical device: http://<your-lan-ip>:3000
|
|
||||||
EXPO_PUBLIC_API_BASE_URL=http://localhost:3000
|
|
||||||
43
MobileApp/.gitignore
vendored
@@ -1,43 +0,0 @@
|
|||||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Expo
|
|
||||||
.expo/
|
|
||||||
dist/
|
|
||||||
web-build/
|
|
||||||
expo-env.d.ts
|
|
||||||
|
|
||||||
# Native
|
|
||||||
.kotlin/
|
|
||||||
*.orig.*
|
|
||||||
*.jks
|
|
||||||
*.p8
|
|
||||||
*.p12
|
|
||||||
*.key
|
|
||||||
*.mobileprovision
|
|
||||||
|
|
||||||
# Metro
|
|
||||||
.metro-health-check*
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.*
|
|
||||||
yarn-debug.*
|
|
||||||
yarn-error.*
|
|
||||||
|
|
||||||
# macOS
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
app-example
|
|
||||||
|
|
||||||
# generated native folders
|
|
||||||
/ios
|
|
||||||
/android
|
|
||||||
1
MobileApp/.vscode/extensions.json
vendored
@@ -1 +0,0 @@
|
|||||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
|
||||||
7
MobileApp/.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll": "explicit",
|
|
||||||
"source.organizeImports": "explicit",
|
|
||||||
"source.sortMembers": "explicit"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# SecureCam Mobile (Expo)
|
|
||||||
|
|
||||||
React Native mobile client for the SecureCam backend.
|
|
||||||
|
|
||||||
## Features migrated from the Svelte web app
|
|
||||||
|
|
||||||
- Email/password auth + account creation
|
|
||||||
- Device onboarding (`camera` or `client` role)
|
|
||||||
- Realtime socket connection status
|
|
||||||
- Client dashboard:
|
|
||||||
- Link/unlink cameras
|
|
||||||
- Rename linked cameras
|
|
||||||
- Request live stream sessions
|
|
||||||
- View frame-based live feed fallback images
|
|
||||||
- Open recordings by presigned URL
|
|
||||||
- Activity feed (motion notifications)
|
|
||||||
- Settings (diagnostics + sign out)
|
|
||||||
- Camera dashboard:
|
|
||||||
- Native camera preview (`expo-camera`)
|
|
||||||
- Frame relay over Socket.IO for stream fallback
|
|
||||||
- Motion start/end controls
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Node.js 20+
|
|
||||||
- npm
|
|
||||||
- Expo CLI via `npx expo ...`
|
|
||||||
- Backend running on port `3000` (default)
|
|
||||||
|
|
||||||
## Environment setup
|
|
||||||
|
|
||||||
Set the backend URL for the mobile app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
EXPO_PUBLIC_API_BASE_URL=http://<HOST>:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
You can place this in `MobileApp/.env`.
|
|
||||||
|
|
||||||
### Common values
|
|
||||||
|
|
||||||
- iOS simulator: `http://localhost:3000`
|
|
||||||
- Android emulator: `http://10.0.2.2:3000`
|
|
||||||
- Physical device (same Wi-Fi): `http://<your-lan-ip>:3000`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
EXPO_PUBLIC_API_BASE_URL=http://192.168.1.25:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install and run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd MobileApp
|
|
||||||
npm install
|
|
||||||
npm run start
|
|
||||||
```
|
|
||||||
|
|
||||||
Then open via Expo Go / simulator.
|
|
||||||
|
|
||||||
## Native module notes
|
|
||||||
|
|
||||||
This app uses `expo-camera` for native camera preview and frame capture.
|
|
||||||
|
|
||||||
If your environment is offline, dependency installation may fail. Once network is available, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd MobileApp
|
|
||||||
npx expo install expo-camera socket.io-client
|
|
||||||
```
|
|
||||||
|
|
||||||
## WebRTC note
|
|
||||||
|
|
||||||
Current native mobile live viewing still uses the legacy `stream:frame` fallback path.
|
|
||||||
|
|
||||||
The backend `SIMPLE_STREAMING` flag therefore defaults to `false` for safe rollout.
|
|
||||||
|
|
||||||
For full native WebRTC publish/subscribe parity, add a supported RN WebRTC stack (for example `react-native-webrtc`) and replace frame relay with peer connection flows equivalent to the web client before enabling `SIMPLE_STREAMING` by default.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd MobileApp
|
|
||||||
npm run lint
|
|
||||||
npx tsc --noEmit
|
|
||||||
```
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"expo": {
|
|
||||||
"name": "MobileApp",
|
|
||||||
"slug": "MobileApp",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"orientation": "portrait",
|
|
||||||
"icon": "./assets/images/icon.png",
|
|
||||||
"scheme": "mobileapp",
|
|
||||||
"userInterfaceStyle": "automatic",
|
|
||||||
"newArchEnabled": true,
|
|
||||||
"ios": {
|
|
||||||
"supportsTablet": true
|
|
||||||
},
|
|
||||||
"android": {
|
|
||||||
"adaptiveIcon": {
|
|
||||||
"backgroundColor": "#E6F4FE",
|
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
|
||||||
},
|
|
||||||
"edgeToEdgeEnabled": true,
|
|
||||||
"predictiveBackGestureEnabled": false
|
|
||||||
},
|
|
||||||
"web": {
|
|
||||||
"output": "static",
|
|
||||||
"favicon": "./assets/images/favicon.png"
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"expo-router",
|
|
||||||
[
|
|
||||||
"expo-camera",
|
|
||||||
{
|
|
||||||
"cameraPermission": "Allow SecureCam to use your camera for live monitoring and motion relay."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"expo-splash-screen",
|
|
||||||
{
|
|
||||||
"image": "./assets/images/splash-icon.png",
|
|
||||||
"imageWidth": 200,
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#ffffff",
|
|
||||||
"dark": {
|
|
||||||
"backgroundColor": "#000000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"experiments": {
|
|
||||||
"typedRoutes": true,
|
|
||||||
"reactCompiler": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
|
||||||
import { Redirect, Tabs } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { useApp } from '@/src/app-context';
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
|
||||||
const { ready, state, unreadCount } = useApp();
|
|
||||||
|
|
||||||
if (!ready) return null;
|
|
||||||
if (!state.session?.session) return <Redirect href={'/auth' as any} />;
|
|
||||||
if (!state.deviceToken) return <Redirect href={'/onboarding' as any} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
screenOptions={{
|
|
||||||
headerStyle: { backgroundColor: '#0f1015' },
|
|
||||||
headerTintColor: '#f9fafb',
|
|
||||||
tabBarStyle: { backgroundColor: '#0f1015', borderTopColor: 'rgba(255,255,255,0.12)' },
|
|
||||||
tabBarActiveTintColor: '#60a5fa',
|
|
||||||
tabBarInactiveTintColor: '#6b7280',
|
|
||||||
sceneStyle: { backgroundColor: '#0a0a0c' },
|
|
||||||
}}>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: state.device?.role === 'camera' ? 'Camera' : 'Dashboard',
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="speedometer-outline" size={size} color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="activity"
|
|
||||||
options={{
|
|
||||||
title: 'Activity',
|
|
||||||
tabBarBadge: unreadCount > 0 ? unreadCount : undefined,
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="notifications-outline" size={size} color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="settings"
|
|
||||||
options={{
|
|
||||||
title: 'Settings',
|
|
||||||
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import React from 'react';
|
|
||||||
import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
|
|
||||||
import { useApp } from '@/src/app-context';
|
|
||||||
|
|
||||||
export default function ActivityScreen() {
|
|
||||||
const { state, actions } = useApp();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
actions.setPage('activity');
|
|
||||||
return undefined;
|
|
||||||
}, [actions]),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safe}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.title}>Activity History</Text>
|
|
||||||
<Pressable style={styles.clearButton} onPress={actions.clearNotifications}>
|
|
||||||
<Text style={styles.clearButtonText}>Clear Read</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
|
||||||
{state.motionNotifications.length === 0 ? (
|
|
||||||
<View style={styles.emptyState}>
|
|
||||||
<Text style={styles.emptyText}>All quiet. No notifications yet.</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
state.motionNotifications.map((notification) => (
|
|
||||||
<Pressable
|
|
||||||
key={notification.id}
|
|
||||||
style={[styles.item, notification.isRead ? styles.readItem : styles.unreadItem]}
|
|
||||||
onPress={() =>
|
|
||||||
void actions.openMotionNotificationTarget(notification.id, notification.cameraDeviceId)
|
|
||||||
}>
|
|
||||||
<Text style={styles.itemMessage}>{notification.message}</Text>
|
|
||||||
<Text style={styles.itemDate}>{new Date(notification.createdAt).toLocaleString()}</Text>
|
|
||||||
</Pressable>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safe: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0a0a0c',
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingTop: 8,
|
|
||||||
paddingBottom: 10,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
clearButton: {
|
|
||||||
borderRadius: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.16)',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
height: 34,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
clearButtonText: {
|
|
||||||
color: '#d1d5db',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
padding: 14,
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
emptyState: {
|
|
||||||
borderRadius: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'dashed',
|
|
||||||
borderColor: 'rgba(255,255,255,0.2)',
|
|
||||||
backgroundColor: '#111218',
|
|
||||||
padding: 26,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
color: '#6b7280',
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
item: {
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
padding: 12,
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
unreadItem: {
|
|
||||||
backgroundColor: '#14213d',
|
|
||||||
borderColor: '#1d4ed8',
|
|
||||||
},
|
|
||||||
readItem: {
|
|
||||||
backgroundColor: '#111218',
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
},
|
|
||||||
itemMessage: {
|
|
||||||
color: '#e5e7eb',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
itemDate: {
|
|
||||||
color: '#6b7280',
|
|
||||||
fontSize: 11,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,673 +0,0 @@
|
|||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
|
||||||
import { useKeepAwake } from 'expo-keep-awake';
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Image,
|
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
SafeAreaView,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
import { useApp } from '@/src/app-context';
|
|
||||||
|
|
||||||
function PromptModal({
|
|
||||||
visible,
|
|
||||||
title,
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: {
|
|
||||||
visible: boolean;
|
|
||||||
title: string;
|
|
||||||
placeholder: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
|
||||||
<View style={styles.modalBackdrop}>
|
|
||||||
<View style={styles.modalCard}>
|
|
||||||
<Text style={styles.modalTitle}>{title}</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.modalInput}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor="#6b7280"
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChange}
|
|
||||||
autoCapitalize="none"
|
|
||||||
/>
|
|
||||||
<View style={styles.modalActions}>
|
|
||||||
<Pressable style={[styles.modalButton, styles.modalCancel]} onPress={onCancel}>
|
|
||||||
<Text style={styles.modalCancelText}>Cancel</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={[styles.modalButton, styles.modalConfirm]} onPress={onConfirm}>
|
|
||||||
<Text style={styles.modalConfirmText}>Save</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CameraDashboard() {
|
|
||||||
const { state, actions } = useApp();
|
|
||||||
const cameraViewRef = useRef<CameraView | null>(null);
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
|
||||||
|
|
||||||
// Prevent auto-lock while the camera dashboard is open in the foreground.
|
|
||||||
useKeepAwake('securecam-camera-dashboard');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
actions.setCameraPermissionGranted(Boolean(permission?.granted));
|
|
||||||
}, [actions, permission?.granted]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
actions.setCameraRef(null);
|
|
||||||
actions.setCameraPreviewReady(false);
|
|
||||||
};
|
|
||||||
}, [actions]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.cardTitle}>Camera Preview</Text>
|
|
||||||
{!permission?.granted ? (
|
|
||||||
<View style={styles.permissionBox}>
|
|
||||||
<Text style={styles.permissionText}>Camera permission is required to publish live frames.</Text>
|
|
||||||
<Pressable style={styles.primaryButton} onPress={() => void requestPermission()}>
|
|
||||||
<Text style={styles.primaryButtonText}>Grant Camera Permission</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.previewBox}>
|
|
||||||
<CameraView
|
|
||||||
ref={(ref) => {
|
|
||||||
cameraViewRef.current = ref;
|
|
||||||
actions.setCameraRef(ref);
|
|
||||||
}}
|
|
||||||
style={styles.previewCamera}
|
|
||||||
facing="back"
|
|
||||||
onCameraReady={() => actions.setCameraPreviewReady(true)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.cardTitle}>Camera Status</Text>
|
|
||||||
<View style={styles.rowBetween}>
|
|
||||||
<Text style={styles.label}>Realtime</Text>
|
|
||||||
<Text style={[styles.status, state.socketConnected ? styles.online : styles.offline]}>
|
|
||||||
{state.socketConnected ? 'ONLINE' : 'OFFLINE'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.rowBetween}>
|
|
||||||
<Text style={styles.label}>Motion</Text>
|
|
||||||
<Text style={[styles.status, state.isMotionActive ? styles.online : styles.offline]}>
|
|
||||||
{state.isMotionActive ? 'ACTIVE' : 'IDLE'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.rowBetween}>
|
|
||||||
<Text style={styles.label}>Preview</Text>
|
|
||||||
<Text style={[styles.status, state.cameraPreviewReady ? styles.online : styles.offline]}>
|
|
||||||
{state.cameraPreviewReady ? 'READY' : 'NOT READY'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.rowBetween}>
|
|
||||||
<Text style={styles.label}>Publishing</Text>
|
|
||||||
<Text style={[styles.status, state.cameraStatus === 'recording' ? styles.online : styles.offline]}>
|
|
||||||
{state.cameraStatus === 'recording' ? 'ACTIVE' : 'IDLE'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.cardTitle}>Actions</Text>
|
|
||||||
{!state.isMotionActive ? (
|
|
||||||
<Pressable style={styles.primaryButton} onPress={() => void actions.startMotion()}>
|
|
||||||
<Text style={styles.primaryButtonText}>Simulate Motion Event</Text>
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<Pressable style={styles.secondaryButton} onPress={() => void actions.endMotion()}>
|
|
||||||
<Text style={styles.secondaryButtonText}>Stop Motion Event</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Pressable style={styles.secondaryButton} onPress={() => void actions.goOnline()}>
|
|
||||||
<Text style={styles.secondaryButtonText}>Reconnect Realtime</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Text style={styles.infoText}>
|
|
||||||
This mobile build remains on the legacy frame-relay path. Add a native WebRTC stack before enabling
|
|
||||||
SIMPLE_STREAMING by default.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.cardTitle}>Logs</Text>
|
|
||||||
{state.activityLog.length === 0 ? (
|
|
||||||
<Text style={styles.emptyText}>Awaiting events...</Text>
|
|
||||||
) : (
|
|
||||||
state.activityLog.map((item) => (
|
|
||||||
<Text key={item.id} style={styles.logLine}>
|
|
||||||
[{new Date(item.createdAt).toLocaleTimeString()}] {item.type}: {item.message}
|
|
||||||
</Text>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClientDashboard() {
|
|
||||||
const { state, actions } = useApp();
|
|
||||||
const [linkModalOpen, setLinkModalOpen] = useState(false);
|
|
||||||
const [cameraIdInput, setCameraIdInput] = useState('');
|
|
||||||
const [renameTargetId, setRenameTargetId] = useState<string | null>(null);
|
|
||||||
const [renameInput, setRenameInput] = useState('');
|
|
||||||
|
|
||||||
const activeCameraLabel = useMemo(() => {
|
|
||||||
const linked = state.linkedCameras.find((camera) => camera.cameraDeviceId === state.activeCameraDeviceId);
|
|
||||||
return linked?.cameraName || linked?.cameraDeviceId || 'Live Feed Viewer';
|
|
||||||
}, [state.activeCameraDeviceId, state.linkedCameras]);
|
|
||||||
|
|
||||||
const isCameraLive = (cameraDeviceId: string) => {
|
|
||||||
const sessionId = state.cameraSessions?.[cameraDeviceId];
|
|
||||||
if (!sessionId) return false;
|
|
||||||
return state.connectedStreamSessionIds.includes(sessionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openRenameModal = (cameraDeviceId: string, currentName?: string | null) => {
|
|
||||||
setRenameTargetId(cameraDeviceId);
|
|
||||||
setRenameInput(currentName || '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitRename = async () => {
|
|
||||||
if (!renameTargetId) return;
|
|
||||||
await actions.renameLinkedCamera(renameTargetId, renameInput);
|
|
||||||
setRenameTargetId(null);
|
|
||||||
setRenameInput('');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
|
||||||
<View style={styles.rowBetween}>
|
|
||||||
<Text style={styles.screenTitle}>Client Dashboard</Text>
|
|
||||||
<View style={styles.rowGap}>
|
|
||||||
<Pressable style={styles.outlineButton} onPress={() => setLinkModalOpen(true)}>
|
|
||||||
<Text style={styles.outlineButtonText}>Link Camera</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable style={styles.iconButton} onPress={() => void actions.refreshClientData()}>
|
|
||||||
<Text style={styles.iconButtonText}>R</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.cardTitle}>Your Cameras</Text>
|
|
||||||
<ScrollView horizontal contentContainerStyle={styles.cameraRow} showsHorizontalScrollIndicator={false}>
|
|
||||||
{state.linkedCameras.length === 0 ? (
|
|
||||||
<View style={styles.emptyCameraCard}>
|
|
||||||
<Text style={styles.emptyText}>No cameras linked yet</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
state.linkedCameras.map((link) => {
|
|
||||||
const selected = state.activeCameraDeviceId === link.cameraDeviceId;
|
|
||||||
const live = isCameraLive(link.cameraDeviceId);
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={link.id}
|
|
||||||
style={[styles.cameraCard, selected ? styles.cameraCardSelected : null]}>
|
|
||||||
<Text style={styles.cameraName}>{link.cameraName || link.cameraDeviceId}</Text>
|
|
||||||
<Text style={[styles.cameraStatus, link.cameraStatus === 'online' ? styles.online : styles.offline]}>
|
|
||||||
{(link.cameraStatus || 'offline').toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.cameraSubtext}>{live ? 'Live stream active' : 'Tap view to request stream'}</Text>
|
|
||||||
|
|
||||||
<View style={styles.cameraActionsRow}>
|
|
||||||
<Pressable style={styles.smallButton} onPress={() => void actions.selectCamera(link.cameraDeviceId)}>
|
|
||||||
<Text style={styles.smallButtonText}>View</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
style={styles.smallButton}
|
|
||||||
onPress={() => openRenameModal(link.cameraDeviceId, link.cameraName)}>
|
|
||||||
<Text style={styles.smallButtonText}>Rename</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.smallButton, styles.smallDangerButton]}
|
|
||||||
onPress={() => {
|
|
||||||
Alert.alert(
|
|
||||||
'Remove Camera',
|
|
||||||
`Remove \"${link.cameraName || link.cameraDeviceId}\" from linked cameras?`,
|
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Delete',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => {
|
|
||||||
void actions.deleteLinkedCamera(link.id);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}}>
|
|
||||||
<Text style={[styles.smallButtonText, styles.smallDangerText]}>Delete</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{state.activeCameraDeviceId ? (
|
|
||||||
<View style={styles.card}>
|
|
||||||
<View style={styles.rowBetween}>
|
|
||||||
<Text style={styles.cardTitle}>Live Feed: {activeCameraLabel}</Text>
|
|
||||||
<Pressable style={styles.iconButton} onPress={actions.closeStreamViewer}>
|
|
||||||
<Text style={styles.iconButtonText}>X</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.streamBox}>
|
|
||||||
{state.clientStreamMode === 'image' && state.clientFallbackFrame ? (
|
|
||||||
<Image source={{ uri: state.clientFallbackFrame }} style={styles.streamImage} resizeMode="contain" />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.streamPlaceholder}>{state.clientPlaceholderText}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.cardTitle}>Recent Recordings</Text>
|
|
||||||
{state.recordings.length === 0 ? (
|
|
||||||
<Text style={styles.emptyText}>No recordings found</Text>
|
|
||||||
) : (
|
|
||||||
state.recordings.slice(0, 5).map((recording) => (
|
|
||||||
<View key={recording.id} style={styles.recordingRow}>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={styles.recordingTitle}>{new Date(recording.createdAt).toLocaleString()}</Text>
|
|
||||||
<Text style={styles.recordingMeta}>
|
|
||||||
{recording.durationSeconds != null ? `${recording.durationSeconds}s` : 'Duration pending'} -{' '}
|
|
||||||
{recording.status ?? 'unknown'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
style={styles.smallButton}
|
|
||||||
disabled={recording.status !== 'ready'}
|
|
||||||
onPress={() => void actions.openRecording(recording.id)}>
|
|
||||||
<Text style={styles.smallButtonText}>Open</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
<PromptModal
|
|
||||||
visible={linkModalOpen}
|
|
||||||
title="Link Camera"
|
|
||||||
placeholder="Camera Device ID"
|
|
||||||
value={cameraIdInput}
|
|
||||||
onChange={setCameraIdInput}
|
|
||||||
onCancel={() => {
|
|
||||||
setLinkModalOpen(false);
|
|
||||||
setCameraIdInput('');
|
|
||||||
}}
|
|
||||||
onConfirm={() => {
|
|
||||||
void actions.linkCamera(cameraIdInput);
|
|
||||||
setLinkModalOpen(false);
|
|
||||||
setCameraIdInput('');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PromptModal
|
|
||||||
visible={Boolean(renameTargetId)}
|
|
||||||
title="Rename Camera"
|
|
||||||
placeholder="Camera name"
|
|
||||||
value={renameInput}
|
|
||||||
onChange={setRenameInput}
|
|
||||||
onCancel={() => {
|
|
||||||
setRenameTargetId(null);
|
|
||||||
setRenameInput('');
|
|
||||||
}}
|
|
||||||
onConfirm={() => {
|
|
||||||
void submitRename();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardScreen() {
|
|
||||||
const { state, actions } = useApp();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
actions.setPage(state.device?.role === 'camera' ? 'camera' : 'client');
|
|
||||||
return undefined;
|
|
||||||
}, [actions, state.device?.role]),
|
|
||||||
);
|
|
||||||
|
|
||||||
return <SafeAreaView style={styles.safe}>{state.device?.role === 'camera' ? <CameraDashboard /> : <ClientDashboard />}</SafeAreaView>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safe: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0a0a0c',
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
padding: 14,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
screenTitle: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
rowBetween: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
rowGap: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 14,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
backgroundColor: '#111218',
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
previewBox: {
|
|
||||||
height: 220,
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.1)',
|
|
||||||
backgroundColor: '#08090d',
|
|
||||||
},
|
|
||||||
previewCamera: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
permissionBox: {
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.1)',
|
|
||||||
backgroundColor: '#08090d',
|
|
||||||
padding: 12,
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
permissionText: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 18,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
online: {
|
|
||||||
color: '#34d399',
|
|
||||||
},
|
|
||||||
offline: {
|
|
||||||
color: '#f87171',
|
|
||||||
},
|
|
||||||
primaryButton: {
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: '#2563eb',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
primaryButtonText: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
secondaryButton: {
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.12)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
secondaryButtonText: {
|
|
||||||
color: '#d1d5db',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
outlineButton: {
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 9,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.18)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
outlineButtonText: {
|
|
||||||
color: '#d1d5db',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
iconButton: {
|
|
||||||
height: 36,
|
|
||||||
width: 36,
|
|
||||||
borderRadius: 9,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.18)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
iconButtonText: {
|
|
||||||
color: '#d1d5db',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
infoText: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 18,
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
color: '#6b7280',
|
|
||||||
fontSize: 12,
|
|
||||||
},
|
|
||||||
logLine: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: 18,
|
|
||||||
},
|
|
||||||
cameraRow: {
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
emptyCameraCard: {
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: 'dashed',
|
|
||||||
borderColor: 'rgba(255,255,255,0.2)',
|
|
||||||
padding: 16,
|
|
||||||
minWidth: 240,
|
|
||||||
},
|
|
||||||
cameraCard: {
|
|
||||||
minWidth: 260,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
padding: 12,
|
|
||||||
backgroundColor: '#0d0f15',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
cameraCardSelected: {
|
|
||||||
borderColor: '#3b82f6',
|
|
||||||
backgroundColor: '#111b2f',
|
|
||||||
},
|
|
||||||
cameraName: {
|
|
||||||
color: '#f3f4f6',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
cameraStatus: {
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
cameraSubtext: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 11,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
cameraActionsRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
smallButton: {
|
|
||||||
borderRadius: 8,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.16)',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
height: 30,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
smallButtonText: {
|
|
||||||
color: '#d1d5db',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
smallDangerButton: {
|
|
||||||
borderColor: 'rgba(248,113,113,0.4)',
|
|
||||||
},
|
|
||||||
smallDangerText: {
|
|
||||||
color: '#fca5a5',
|
|
||||||
},
|
|
||||||
streamBox: {
|
|
||||||
height: 220,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.1)',
|
|
||||||
backgroundColor: '#08090d',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
streamImage: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
streamPlaceholder: {
|
|
||||||
color: '#6b7280',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
recordingRow: {
|
|
||||||
borderRadius: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
backgroundColor: '#0d0f15',
|
|
||||||
padding: 10,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
recordingTitle: {
|
|
||||||
color: '#e5e7eb',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
recordingMeta: {
|
|
||||||
color: '#6b7280',
|
|
||||||
fontSize: 11,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
modalBackdrop: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
modalCard: {
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 360,
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: 14,
|
|
||||||
backgroundColor: '#111218',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.1)',
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
modalTitle: {
|
|
||||||
color: '#f3f4f6',
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
modalInput: {
|
|
||||||
height: 46,
|
|
||||||
borderRadius: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.14)',
|
|
||||||
backgroundColor: '#0a0a0e',
|
|
||||||
color: '#f3f4f6',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
},
|
|
||||||
modalActions: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
modalButton: {
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 8,
|
|
||||||
minWidth: 76,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
},
|
|
||||||
modalCancel: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.16)',
|
|
||||||
},
|
|
||||||
modalConfirm: {
|
|
||||||
backgroundColor: '#2563eb',
|
|
||||||
},
|
|
||||||
modalCancelText: {
|
|
||||||
color: '#d1d5db',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
modalConfirmText: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { useFocusEffect } from '@react-navigation/native';
|
|
||||||
import React from 'react';
|
|
||||||
import { Pressable, SafeAreaView, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
||||||
|
|
||||||
import { useApp } from '@/src/app-context';
|
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
|
||||||
const { state, actions } = useApp();
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
actions.setPage('settings');
|
|
||||||
return undefined;
|
|
||||||
}, [actions]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const profileName = state.session?.user?.name || 'User';
|
|
||||||
const profileEmail = state.session?.user?.email || 'user@example.com';
|
|
||||||
const profileInitial = (profileName[0] || 'U').toUpperCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safe}>
|
|
||||||
<ScrollView contentContainerStyle={styles.content}>
|
|
||||||
<Text style={styles.title}>Settings</Text>
|
|
||||||
|
|
||||||
<View style={styles.profileCard}>
|
|
||||||
<View style={styles.avatar}>
|
|
||||||
<Text style={styles.avatarText}>{profileInitial}</Text>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<Text style={styles.profileName}>{profileName}</Text>
|
|
||||||
<Text style={styles.profileEmail}>{profileEmail}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.menuCard}>
|
|
||||||
<Pressable style={styles.menuItem} onPress={actions.runDiagnostics}>
|
|
||||||
<Text style={styles.menuTitle}>Run Diagnostics</Text>
|
|
||||||
<Text style={styles.menuArrow}>{'>'}</Text>
|
|
||||||
</Pressable>
|
|
||||||
<View style={styles.divider} />
|
|
||||||
<View style={styles.menuItem}>
|
|
||||||
<Text style={styles.menuTitle}>Device Information</Text>
|
|
||||||
<Text style={styles.menuArrow}>{'>'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable style={styles.signOutButton} onPress={() => void actions.signOut()}>
|
|
||||||
<Text style={styles.signOutText}>Sign Out</Text>
|
|
||||||
</Pressable>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safe: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0a0a0c',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
padding: 14,
|
|
||||||
gap: 14,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
profileCard: {
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 14,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
backgroundColor: '#111218',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: '#1d4ed8',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
avatarText: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
profileName: {
|
|
||||||
color: '#f3f4f6',
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
profileEmail: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
marginTop: 3,
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
menuCard: {
|
|
||||||
borderRadius: 16,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
backgroundColor: '#111218',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
menuItem: {
|
|
||||||
minHeight: 52,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
menuTitle: {
|
|
||||||
color: '#e5e7eb',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
menuArrow: {
|
|
||||||
color: '#6b7280',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
divider: {
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
||||||
},
|
|
||||||
signOutButton: {
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(248,113,113,0.5)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
signOutText: {
|
|
||||||
color: '#fca5a5',
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
import { StatusBar } from 'expo-status-bar';
|
|
||||||
import React from 'react';
|
|
||||||
import { View } from 'react-native';
|
|
||||||
|
|
||||||
import { AppProvider } from '@/src/app-context';
|
|
||||||
import { ToastOverlay } from '@/src/components/toast-overlay';
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
|
||||||
return (
|
|
||||||
<AppProvider>
|
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="auth" />
|
|
||||||
<Stack.Screen name="onboarding" />
|
|
||||||
<Stack.Screen name="(tabs)" />
|
|
||||||
</Stack>
|
|
||||||
<ToastOverlay />
|
|
||||||
<StatusBar style="light" />
|
|
||||||
</View>
|
|
||||||
</AppProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import { Redirect } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
SafeAreaView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
import { useApp } from '@/src/app-context';
|
|
||||||
|
|
||||||
export default function AuthScreen() {
|
|
||||||
const { ready, state, actions } = useApp();
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator color="#60a5fa" size="large" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.session?.session) {
|
|
||||||
return <Redirect href={(state.deviceToken ? '/(tabs)' : '/onboarding') as any} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safe}>
|
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.container}>
|
|
||||||
<View style={styles.logoWrap}>
|
|
||||||
<View style={styles.logoBadge}>
|
|
||||||
<Text style={styles.logoIcon}>SC</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.title}>SecureCam Mobile</Text>
|
|
||||||
<Text style={styles.subtitle}>Sign in to manage visual security.</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
autoCapitalize="none"
|
|
||||||
keyboardType="email-address"
|
|
||||||
placeholder="Email address"
|
|
||||||
placeholderTextColor="#6b7280"
|
|
||||||
value={state.authForm.email}
|
|
||||||
onChangeText={(value) => actions.setAuthField('email', value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
secureTextEntry
|
|
||||||
placeholder="Password"
|
|
||||||
placeholderTextColor="#6b7280"
|
|
||||||
value={state.authForm.password}
|
|
||||||
onChangeText={(value) => actions.setAuthField('password', value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{state.isRegistering ? (
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
placeholder="Your name"
|
|
||||||
placeholderTextColor="#6b7280"
|
|
||||||
value={state.authForm.name}
|
|
||||||
onChangeText={(value) => actions.setAuthField('name', value)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Pressable style={styles.primaryButton} onPress={() => void actions.submitAuth()}>
|
|
||||||
<Text style={styles.primaryText}>{state.isRegistering ? 'Create Account' : 'Sign In'}</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable style={styles.secondaryButton} onPress={actions.toggleAuthMode}>
|
|
||||||
<Text style={styles.secondaryText}>
|
|
||||||
{state.isRegistering ? 'I already have an account' : 'Create an account'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safe: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0a0a0c',
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0a0a0c',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingHorizontal: 22,
|
|
||||||
},
|
|
||||||
logoWrap: {
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 30,
|
|
||||||
},
|
|
||||||
logoBadge: {
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
borderRadius: 24,
|
|
||||||
backgroundColor: '#1d4ed8',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginBottom: 14,
|
|
||||||
},
|
|
||||||
logoIcon: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
marginTop: 8,
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 13,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: '#111218',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
padding: 18,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.12)',
|
|
||||||
backgroundColor: '#09090d',
|
|
||||||
color: '#f3f4f6',
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
},
|
|
||||||
primaryButton: {
|
|
||||||
marginTop: 6,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: '#2563eb',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
primaryText: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
secondaryButton: {
|
|
||||||
height: 42,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
secondaryText: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Redirect } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import { ActivityIndicator, StyleSheet, View } from 'react-native';
|
|
||||||
|
|
||||||
import { useApp } from '@/src/app-context';
|
|
||||||
|
|
||||||
export default function IndexRoute() {
|
|
||||||
const { ready, state } = useApp();
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator color="#60a5fa" size="large" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.session?.session) {
|
|
||||||
return <Redirect href={'/auth' as any} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.deviceToken) {
|
|
||||||
return <Redirect href={'/onboarding' as any} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Redirect href="/(tabs)" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
loading: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0a0a0c',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import { Redirect } from 'expo-router';
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Pressable,
|
|
||||||
SafeAreaView,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
View,
|
|
||||||
} from 'react-native';
|
|
||||||
|
|
||||||
import { useApp } from '@/src/app-context';
|
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
|
||||||
const { ready, state, actions } = useApp();
|
|
||||||
|
|
||||||
if (!ready) {
|
|
||||||
return (
|
|
||||||
<View style={styles.loading}>
|
|
||||||
<ActivityIndicator color="#60a5fa" size="large" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.session?.session) {
|
|
||||||
return <Redirect href={'/auth' as any} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.deviceToken) {
|
|
||||||
return <Redirect href="/(tabs)" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.safe}>
|
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.container}>
|
|
||||||
<View style={styles.heading}>
|
|
||||||
<Text style={styles.title}>Configure Device</Text>
|
|
||||||
<Text style={styles.subtitle}>Set up this mobile dashboard role.</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.label}>Device Name</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
placeholder="e.g. Living Room Client"
|
|
||||||
placeholderTextColor="#6b7280"
|
|
||||||
value={state.onboardingForm.name}
|
|
||||||
onChangeText={(value) => actions.setOnboardingField('name', value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text style={styles.label}>Role</Text>
|
|
||||||
<View style={styles.roleRow}>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.roleButton, state.onboardingForm.role === 'camera' ? styles.roleButtonActive : null]}
|
|
||||||
onPress={() => actions.selectRole('camera')}>
|
|
||||||
<Text style={[styles.roleText, state.onboardingForm.role === 'camera' ? styles.roleTextActive : null]}>
|
|
||||||
Camera
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.roleButton, state.onboardingForm.role === 'client' ? styles.roleButtonActive : null]}
|
|
||||||
onPress={() => actions.selectRole('client')}>
|
|
||||||
<Text style={[styles.roleText, state.onboardingForm.role === 'client' ? styles.roleTextActive : null]}>
|
|
||||||
Client
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={styles.label}>Push Token (Optional)</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
placeholder="simulated_token_123"
|
|
||||||
placeholderTextColor="#6b7280"
|
|
||||||
value={state.onboardingForm.pushToken}
|
|
||||||
onChangeText={(value) => actions.setOnboardingField('pushToken', value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Pressable style={styles.primaryButton} onPress={() => void actions.registerDevice()}>
|
|
||||||
<Text style={styles.primaryText}>Complete Setup</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable style={styles.secondaryButton} onPress={() => void actions.loadSavedDevice()}>
|
|
||||||
<Text style={styles.secondaryText}>Load previously saved device</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safe: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0a0a0c',
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0a0a0c',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
},
|
|
||||||
heading: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 13,
|
|
||||||
marginTop: 6,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
borderRadius: 20,
|
|
||||||
backgroundColor: '#111218',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
padding: 18,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 11,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.12)',
|
|
||||||
backgroundColor: '#09090d',
|
|
||||||
color: '#f3f4f6',
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
},
|
|
||||||
roleRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
roleButton: {
|
|
||||||
flex: 1,
|
|
||||||
height: 42,
|
|
||||||
borderRadius: 10,
|
|
||||||
backgroundColor: '#1f2230',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
roleButtonActive: {
|
|
||||||
backgroundColor: '#1d4ed8',
|
|
||||||
borderColor: '#2563eb',
|
|
||||||
},
|
|
||||||
roleText: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
roleTextActive: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
},
|
|
||||||
primaryButton: {
|
|
||||||
marginTop: 6,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 12,
|
|
||||||
backgroundColor: '#2563eb',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
primaryText: {
|
|
||||||
color: '#f9fafb',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
secondaryButton: {
|
|
||||||
height: 42,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
secondaryText: {
|
|
||||||
color: '#9ca3af',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 384 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 17 KiB |
2030
MobileApp/bun.lock
@@ -1,10 +0,0 @@
|
|||||||
// https://docs.expo.dev/guides/using-eslint/
|
|
||||||
const { defineConfig } = require('eslint/config');
|
|
||||||
const expoConfig = require('eslint-config-expo/flat');
|
|
||||||
|
|
||||||
module.exports = defineConfig([
|
|
||||||
expoConfig,
|
|
||||||
{
|
|
||||||
ignores: ['dist/*'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
12862
MobileApp/package-lock.json
generated
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mobileapp",
|
|
||||||
"main": "expo-router/entry",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"scripts": {
|
|
||||||
"start": "expo start",
|
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
|
||||||
"android": "expo start --android",
|
|
||||||
"ios": "expo start --ios",
|
|
||||||
"web": "expo start --web",
|
|
||||||
"lint": "expo lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@expo/vector-icons": "^15.0.3",
|
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
|
||||||
"@react-navigation/elements": "^2.6.3",
|
|
||||||
"@react-navigation/native": "^7.1.8",
|
|
||||||
"expo": "~54.0.33",
|
|
||||||
"expo-camera": "~17.0.10",
|
|
||||||
"expo-constants": "~18.0.13",
|
|
||||||
"expo-font": "~14.0.11",
|
|
||||||
"expo-haptics": "~15.0.8",
|
|
||||||
"expo-image": "~3.0.11",
|
|
||||||
"expo-keep-awake": "~15.0.8",
|
|
||||||
"expo-linking": "~8.0.11",
|
|
||||||
"expo-router": "~6.0.23",
|
|
||||||
"expo-splash-screen": "~31.0.13",
|
|
||||||
"expo-status-bar": "~3.0.9",
|
|
||||||
"expo-symbols": "~1.0.8",
|
|
||||||
"expo-system-ui": "~6.0.9",
|
|
||||||
"expo-web-browser": "~15.0.10",
|
|
||||||
"react": "19.1.0",
|
|
||||||
"react-dom": "19.1.0",
|
|
||||||
"react-native": "0.81.5",
|
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
|
||||||
"react-native-reanimated": "~4.1.1",
|
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
|
||||||
"react-native-screens": "~4.16.0",
|
|
||||||
"react-native-web": "~0.21.0",
|
|
||||||
"react-native-worklets": "0.5.1",
|
|
||||||
"socket.io-client": "^4.8.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "~19.1.0",
|
|
||||||
"typescript": "~5.9.2",
|
|
||||||
"eslint": "^9.25.0",
|
|
||||||
"eslint-config-expo": "~10.0.0"
|
|
||||||
},
|
|
||||||
"private": true
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This script is used to reset the project to a blank state.
|
|
||||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
|
||||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const readline = require("readline");
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
|
||||||
const exampleDir = "app-example";
|
|
||||||
const newAppDir = "app";
|
|
||||||
const exampleDirPath = path.join(root, exampleDir);
|
|
||||||
|
|
||||||
const indexContent = `import { Text, View } from "react-native";
|
|
||||||
|
|
||||||
export default function Index() {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const layoutContent = `import { Stack } from "expo-router";
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
|
||||||
return <Stack />;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveDirectories = async (userInput) => {
|
|
||||||
try {
|
|
||||||
if (userInput === "y") {
|
|
||||||
// Create the app-example directory
|
|
||||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
|
||||||
console.log(`📁 /${exampleDir} directory created.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move old directories to new app-example directory or delete them
|
|
||||||
for (const dir of oldDirs) {
|
|
||||||
const oldDirPath = path.join(root, dir);
|
|
||||||
if (fs.existsSync(oldDirPath)) {
|
|
||||||
if (userInput === "y") {
|
|
||||||
const newDirPath = path.join(root, exampleDir, dir);
|
|
||||||
await fs.promises.rename(oldDirPath, newDirPath);
|
|
||||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
|
||||||
} else {
|
|
||||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
|
||||||
console.log(`❌ /${dir} deleted.`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new /app directory
|
|
||||||
const newAppDirPath = path.join(root, newAppDir);
|
|
||||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
|
||||||
console.log("\n📁 New /app directory created.");
|
|
||||||
|
|
||||||
// Create index.tsx
|
|
||||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
|
||||||
await fs.promises.writeFile(indexPath, indexContent);
|
|
||||||
console.log("📄 app/index.tsx created.");
|
|
||||||
|
|
||||||
// Create _layout.tsx
|
|
||||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
|
||||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
|
||||||
console.log("📄 app/_layout.tsx created.");
|
|
||||||
|
|
||||||
console.log("\n✅ Project reset complete. Next steps:");
|
|
||||||
console.log(
|
|
||||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
|
||||||
userInput === "y"
|
|
||||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Error during script execution: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rl.question(
|
|
||||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
|
||||||
(answer) => {
|
|
||||||
const userInput = answer.trim().toLowerCase() || "y";
|
|
||||||
if (userInput === "y" || userInput === "n") {
|
|
||||||
moveDirectories(userInput).finally(() => rl.close());
|
|
||||||
} else {
|
|
||||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { API_BASE_URL } from '@/src/config';
|
|
||||||
|
|
||||||
type TokenGetter = () => string | null;
|
|
||||||
|
|
||||||
type RequestOptions = RequestInit & {
|
|
||||||
skipAuth?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePath = (path: string): string => {
|
|
||||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
|
||||||
return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createApi = (getDeviceToken: TokenGetter) => {
|
|
||||||
const request = async <T = any>(path: string, options: RequestOptions = {}): Promise<T> => {
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(options.headers as Record<string, string> | undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!options.skipAuth) {
|
|
||||||
const token = getDeviceToken();
|
|
||||||
if (token) {
|
|
||||||
headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(normalizePath(path), {
|
|
||||||
...options,
|
|
||||||
headers,
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message =
|
|
||||||
(data as { message?: string; error?: string }).message ||
|
|
||||||
(data as { message?: string; error?: string }).error ||
|
|
||||||
response.statusText ||
|
|
||||||
'Request failed';
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
request,
|
|
||||||
auth: {
|
|
||||||
signUp: (data: { email: string; password: string; name: string }) =>
|
|
||||||
request('/api/auth/sign-up/email', { method: 'POST', body: JSON.stringify(data), skipAuth: true }),
|
|
||||||
signIn: (data: { email: string; password: string }) =>
|
|
||||||
request('/api/auth/sign-in/email', { method: 'POST', body: JSON.stringify(data), skipAuth: true }),
|
|
||||||
getSession: () => request('/api/auth/get-session', { skipAuth: true }),
|
|
||||||
signOut: () => request('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}), skipAuth: true }),
|
|
||||||
},
|
|
||||||
devices: {
|
|
||||||
register: (data: Record<string, unknown>) =>
|
|
||||||
request<{ device: any; deviceToken: string }>('/devices/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
|
||||||
list: () => request<{ devices: any[] }>('/devices'),
|
|
||||||
update: (deviceId: string, data: Record<string, unknown>) =>
|
|
||||||
request(`/devices/${deviceId}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
||||||
listLinks: () => request<{ links: any[] }>('/device-links'),
|
|
||||||
link: (cameraDeviceId: string, clientDeviceId: string) =>
|
|
||||||
request('/device-links', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ cameraDeviceId, clientDeviceId }),
|
|
||||||
}),
|
|
||||||
unlink: (linkId: string) => request(`/device-links/${linkId}`, { method: 'DELETE' }),
|
|
||||||
},
|
|
||||||
streams: {
|
|
||||||
request: (cameraDeviceId: string) =>
|
|
||||||
request('/streams/request', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }),
|
|
||||||
}),
|
|
||||||
accept: (id: string) => request(`/streams/${id}/accept`, { method: 'POST', body: JSON.stringify({}) }),
|
|
||||||
end: (id: string) =>
|
|
||||||
request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) }),
|
|
||||||
getPublishCreds: (id: string) => request(`/streams/${id}/publish-credentials`),
|
|
||||||
getSubscribeCreds: (id: string) => request(`/streams/${id}/subscribe-credentials`),
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
startMotion: () =>
|
|
||||||
request<{ event: { id: string } }>('/events/motion/start', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion' }),
|
|
||||||
}),
|
|
||||||
endMotion: (id: string) =>
|
|
||||||
request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }),
|
|
||||||
finalizeRecording: (id: string, payload: Record<string, unknown>) =>
|
|
||||||
request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) }),
|
|
||||||
},
|
|
||||||
ops: {
|
|
||||||
listRecordings: () => request<{ recordings: any[] }>('/recordings/me/list'),
|
|
||||||
getRecordingDownloadUrl: (recordingId: string) => request<{ downloadUrl: string }>(`/recordings/${recordingId}/download-url`),
|
|
||||||
listNotifications: () => request('/push-notifications/me'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ApiClient = ReturnType<typeof createApi>;
|
|
||||||
@@ -1,847 +0,0 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { io, type Socket } from 'socket.io-client';
|
|
||||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { Linking } from 'react-native';
|
|
||||||
import type { CameraView } from 'expo-camera';
|
|
||||||
|
|
||||||
import { createApi } from '@/src/api';
|
|
||||||
import { API_BASE_URL } from '@/src/config';
|
|
||||||
import {
|
|
||||||
createInitialState,
|
|
||||||
type AppPage,
|
|
||||||
type AppState,
|
|
||||||
type LinkedCamera,
|
|
||||||
type MotionNotification,
|
|
||||||
unreadNotificationsCount,
|
|
||||||
} from '@/src/state';
|
|
||||||
|
|
||||||
const DEVICE_STORAGE_KEY = 'mobileSimDevice';
|
|
||||||
|
|
||||||
type AppContextValue = {
|
|
||||||
state: AppState;
|
|
||||||
ready: boolean;
|
|
||||||
unreadCount: number;
|
|
||||||
actions: AppActions;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AppActions = {
|
|
||||||
setPage: (page: AppPage) => void;
|
|
||||||
setAuthField: (field: 'email' | 'password' | 'name', value: string) => void;
|
|
||||||
toggleAuthMode: () => void;
|
|
||||||
submitAuth: () => Promise<void>;
|
|
||||||
setOnboardingField: (field: 'name' | 'pushToken', value: string) => void;
|
|
||||||
selectRole: (role: 'camera' | 'client') => void;
|
|
||||||
registerDevice: () => Promise<void>;
|
|
||||||
loadSavedDevice: () => Promise<void>;
|
|
||||||
signOut: () => Promise<void>;
|
|
||||||
startMotion: () => Promise<void>;
|
|
||||||
endMotion: () => Promise<void>;
|
|
||||||
goOnline: () => Promise<void>;
|
|
||||||
refreshCameraInputs: () => Promise<void>;
|
|
||||||
selectCameraInput: (_cameraInputId: string) => Promise<void>;
|
|
||||||
linkCamera: (cameraDeviceId: string) => Promise<void>;
|
|
||||||
renameLinkedCamera: (cameraDeviceId: string, nextName: string) => Promise<void>;
|
|
||||||
deleteLinkedCamera: (linkId: string) => Promise<void>;
|
|
||||||
requestStream: (cameraDeviceId: string) => Promise<void>;
|
|
||||||
selectCamera: (cameraDeviceId: string) => Promise<void>;
|
|
||||||
closeStreamViewer: () => void;
|
|
||||||
openRecording: (recordingId: string) => Promise<void>;
|
|
||||||
openMotionNotificationTarget: (notificationId: string, cameraDeviceId: string) => Promise<void>;
|
|
||||||
markAllNotificationsRead: () => void;
|
|
||||||
clearNotifications: () => void;
|
|
||||||
refreshClientData: () => Promise<void>;
|
|
||||||
runDiagnostics: () => void;
|
|
||||||
removeToast: (id: string) => void;
|
|
||||||
setCameraPermissionGranted: (granted: boolean) => void;
|
|
||||||
setCameraPreviewReady: (ready: boolean) => void;
|
|
||||||
setCameraRef: (ref: CameraView | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AppContext = createContext<AppContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
const makeId = (): string => {
|
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCameraLabel = (cameraDeviceId: string, cameraName?: string | null): string => {
|
|
||||||
const explicitName = cameraName?.trim();
|
|
||||||
if (explicitName) return explicitName;
|
|
||||||
return `Camera ${cameraDeviceId.slice(0, 6)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [state, setState] = useState<AppState>(createInitialState());
|
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
|
|
||||||
const stateRef = useRef(state);
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const requestedStreamsRef = useRef<Set<string>>(new Set());
|
|
||||||
const lastMotionEventIdRef = useRef<string | null>(null);
|
|
||||||
const activeRecordingStreamSessionIdRef = useRef<string | null>(null);
|
|
||||||
const frameRelayTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const frameRelayBusyRef = useRef(false);
|
|
||||||
const cameraRef = useRef<CameraView | null>(null);
|
|
||||||
const initDoneRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
stateRef.current = state;
|
|
||||||
}, [state]);
|
|
||||||
|
|
||||||
const api = useMemo(() => createApi(() => stateRef.current.deviceToken), []);
|
|
||||||
|
|
||||||
const setAppState = (partial: Partial<AppState>) => {
|
|
||||||
setState((prev) => ({ ...prev, ...partial }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const patchAppState = (updater: (prev: AppState) => Partial<AppState>) => {
|
|
||||||
setState((prev) => ({ ...prev, ...updater(prev) }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setClientStreamMode = (mode: AppState['clientStreamMode']) => {
|
|
||||||
let text = 'Select a camera to view';
|
|
||||||
if (mode === 'connecting') text = 'Connecting stream...';
|
|
||||||
if (mode === 'unavailable') text = 'Stream unavailable';
|
|
||||||
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
clientStreamMode: mode,
|
|
||||||
clientPlaceholderText: text,
|
|
||||||
clientFallbackFrame: mode === 'image' ? prev.clientFallbackFrame : '',
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const pushToast = (message: string, type: 'info' | 'success' | 'error' = 'info') => {
|
|
||||||
const id = makeId();
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
toasts: [...prev.toasts, { id, message, type }].slice(-6),
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setState((prev) => ({ ...prev, toasts: prev.toasts.filter((toast) => toast.id !== id) }));
|
|
||||||
}, 3200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeToast = (id: string) => {
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
toasts: prev.toasts.filter((toast) => toast.id !== id),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addActivity = (type: string, message: string) => {
|
|
||||||
const item = {
|
|
||||||
id: makeId(),
|
|
||||||
type,
|
|
||||||
message,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
activityLog: [item, ...prev.activityLog].slice(0, 200),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const markMotionNotificationRead = (notificationId: string) => {
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
motionNotifications: prev.motionNotifications.map((notification) =>
|
|
||||||
notification.id === notificationId ? { ...notification, isRead: true } : notification,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const markAllNotificationsRead = () => {
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
motionNotifications: prev.motionNotifications.map((notification) =>
|
|
||||||
notification.isRead ? notification : { ...notification, isRead: true },
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopPolling = () => {
|
|
||||||
if (pollTimerRef.current) {
|
|
||||||
clearInterval(pollTimerRef.current);
|
|
||||||
pollTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearClientStream = () => {
|
|
||||||
setClientStreamMode('none');
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopFrameRelay = () => {
|
|
||||||
if (frameRelayTimerRef.current) {
|
|
||||||
clearInterval(frameRelayTimerRef.current);
|
|
||||||
frameRelayTimerRef.current = null;
|
|
||||||
}
|
|
||||||
frameRelayBusyRef.current = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const captureAndRelayFrame = async (streamSessionId: string, toDeviceId: string) => {
|
|
||||||
if (frameRelayBusyRef.current) return;
|
|
||||||
if (!socketRef.current || !cameraRef.current) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
frameRelayBusyRef.current = true;
|
|
||||||
const photo = await cameraRef.current.takePictureAsync({
|
|
||||||
base64: true,
|
|
||||||
quality: 0.45,
|
|
||||||
skipProcessing: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!photo?.base64) return;
|
|
||||||
|
|
||||||
socketRef.current.emit('stream:frame', {
|
|
||||||
toDeviceId,
|
|
||||||
streamSessionId,
|
|
||||||
frame: `data:image/jpeg;base64,${photo.base64}`,
|
|
||||||
capturedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// ignore transient camera capture errors
|
|
||||||
} finally {
|
|
||||||
frameRelayBusyRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startFrameRelay = (streamSessionId: string, toDeviceId: string) => {
|
|
||||||
if (!streamSessionId || !toDeviceId) return;
|
|
||||||
stopFrameRelay();
|
|
||||||
|
|
||||||
frameRelayTimerRef.current = setInterval(() => {
|
|
||||||
void captureAndRelayFrame(streamSessionId, toDeviceId);
|
|
||||||
}, 700);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pollClientData = async () => {
|
|
||||||
const current = stateRef.current;
|
|
||||||
if (!current.device || current.device.role !== 'client') return;
|
|
||||||
|
|
||||||
const [recs, links, deviceList] = await Promise.all([
|
|
||||||
api.ops.listRecordings().catch(() => ({ recordings: [] })),
|
|
||||||
api.devices.listLinks().catch(() => ({ links: [] })),
|
|
||||||
api.devices.list().catch(() => ({ devices: [] })),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const cameraById = new Map(
|
|
||||||
(deviceList.devices || [])
|
|
||||||
.filter((entry) => entry.role === 'camera')
|
|
||||||
.map((entry) => [entry.id, entry]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const linkedCameras: LinkedCamera[] = (links.links || []).map((link) => {
|
|
||||||
const camera = cameraById.get(link.cameraDeviceId);
|
|
||||||
return {
|
|
||||||
...link,
|
|
||||||
cameraName: camera?.name ?? null,
|
|
||||||
cameraStatus: camera?.status ?? 'offline',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setAppState({
|
|
||||||
recordings: recs.recordings || [],
|
|
||||||
linkedCameras,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const link of linkedCameras) {
|
|
||||||
if (!requestedStreamsRef.current.has(link.cameraDeviceId)) {
|
|
||||||
requestedStreamsRef.current.add(link.cameraDeviceId);
|
|
||||||
await api.streams.request(link.cameraDeviceId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startPolling = () => {
|
|
||||||
stopPolling();
|
|
||||||
void pollClientData();
|
|
||||||
pollTimerRef.current = setInterval(() => {
|
|
||||||
void pollClientData();
|
|
||||||
}, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const disconnectSocket = () => {
|
|
||||||
stopFrameRelay();
|
|
||||||
if (socketRef.current) {
|
|
||||||
socketRef.current.disconnect();
|
|
||||||
socketRef.current = null;
|
|
||||||
}
|
|
||||||
setAppState({ socketConnected: false, connectedStreamSessionIds: [] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectSocket = () => {
|
|
||||||
const token = stateRef.current.deviceToken;
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
disconnectSocket();
|
|
||||||
|
|
||||||
const socket = io(API_BASE_URL, {
|
|
||||||
auth: { token },
|
|
||||||
transports: ['websocket'],
|
|
||||||
});
|
|
||||||
|
|
||||||
socketRef.current = socket;
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
setAppState({ socketConnected: true });
|
|
||||||
addActivity('System', 'Connected to realtime server');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
stopFrameRelay();
|
|
||||||
activeRecordingStreamSessionIdRef.current = null;
|
|
||||||
setAppState({ socketConnected: false, activeStreamSessionId: null, cameraStatus: 'idle' });
|
|
||||||
clearClientStream();
|
|
||||||
addActivity('System', 'Realtime disconnected');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('command:received', async (payload) => {
|
|
||||||
addActivity('Command', `Received ${payload.commandType}`);
|
|
||||||
|
|
||||||
if (payload.commandType !== 'start_stream') {
|
|
||||||
socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const streamId = payload?.payload?.streamSessionId;
|
|
||||||
const sourceDeviceId = payload?.sourceDeviceId;
|
|
||||||
if (streamId) {
|
|
||||||
await api.streams.accept(streamId);
|
|
||||||
await api.streams.getPublishCreds(streamId).catch(() => undefined);
|
|
||||||
activeRecordingStreamSessionIdRef.current = streamId;
|
|
||||||
setAppState({ cameraStatus: 'recording' });
|
|
||||||
if (sourceDeviceId) {
|
|
||||||
startFrameRelay(streamId, sourceDeviceId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
addActivity('Stream', 'Accepted stream command and started camera frame relay');
|
|
||||||
socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' });
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Command rejected';
|
|
||||||
socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('motion:detected', (payload) => {
|
|
||||||
const cameraDeviceId = payload.cameraDeviceId || payload.deviceId;
|
|
||||||
if (!cameraDeviceId) return;
|
|
||||||
|
|
||||||
const notification: MotionNotification = {
|
|
||||||
id: makeId(),
|
|
||||||
cameraDeviceId,
|
|
||||||
message: `${getCameraLabel(cameraDeviceId)} has detected movement`,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
isRead: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
motionNotifications: [notification, ...prev.motionNotifications].slice(0, 50),
|
|
||||||
activeCameraDeviceId: cameraDeviceId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
void api.streams.request(cameraDeviceId).catch(() => undefined);
|
|
||||||
pushToast('Motion detected', 'info');
|
|
||||||
addActivity('Motion', notification.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('stream:started', async (payload) => {
|
|
||||||
const current = stateRef.current;
|
|
||||||
const cameraSessions = { ...current.cameraSessions, [payload.cameraDeviceId]: payload.streamSessionId };
|
|
||||||
|
|
||||||
setAppState({ cameraSessions });
|
|
||||||
addActivity('Stream', 'Stream is live, connecting...');
|
|
||||||
|
|
||||||
if (payload.cameraDeviceId === current.activeCameraDeviceId) {
|
|
||||||
setAppState({ activeStreamSessionId: payload.streamSessionId });
|
|
||||||
setClientStreamMode('connecting');
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.streams.getSubscribeCreds(payload.streamSessionId).catch(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('stream:frame', (payload) => {
|
|
||||||
if (!payload?.frame || payload.streamSessionId !== stateRef.current.activeStreamSessionId) return;
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
clientFallbackFrame: payload.frame,
|
|
||||||
connectedStreamSessionIds: prev.connectedStreamSessionIds.includes(payload.streamSessionId)
|
|
||||||
? prev.connectedStreamSessionIds
|
|
||||||
: [...prev.connectedStreamSessionIds, payload.streamSessionId],
|
|
||||||
}));
|
|
||||||
setClientStreamMode('image');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('stream:ended', (payload) => {
|
|
||||||
if (!payload?.streamSessionId) return;
|
|
||||||
if (activeRecordingStreamSessionIdRef.current === payload.streamSessionId) {
|
|
||||||
stopFrameRelay();
|
|
||||||
activeRecordingStreamSessionIdRef.current = null;
|
|
||||||
setAppState({ cameraStatus: 'idle' });
|
|
||||||
}
|
|
||||||
if (payload.streamSessionId === stateRef.current.activeStreamSessionId) {
|
|
||||||
setAppState({ activeStreamSessionId: null });
|
|
||||||
clearClientStream();
|
|
||||||
}
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
connectedStreamSessionIds: prev.connectedStreamSessionIds.filter((id) => id !== payload.streamSessionId),
|
|
||||||
}));
|
|
||||||
addActivity('Stream', 'Remote stream ended');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error:webrtc_signal', (payload) => {
|
|
||||||
const message = payload?.message || 'WebRTC signaling error';
|
|
||||||
addActivity('WebRTC', message);
|
|
||||||
pushToast(message, 'error');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const cleanupConnectionState = async () => {
|
|
||||||
stopPolling();
|
|
||||||
stopFrameRelay();
|
|
||||||
activeRecordingStreamSessionIdRef.current = null;
|
|
||||||
disconnectSocket();
|
|
||||||
requestedStreamsRef.current.clear();
|
|
||||||
setAppState({
|
|
||||||
activeCameraDeviceId: null,
|
|
||||||
activeStreamSessionId: null,
|
|
||||||
cameraSessions: {},
|
|
||||||
connectedStreamSessionIds: [],
|
|
||||||
cameraStatus: 'idle',
|
|
||||||
clientFallbackFrame: '',
|
|
||||||
clientStreamMode: 'none',
|
|
||||||
clientPlaceholderText: 'Select a camera to view',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// This initialization intentionally runs once; lifecycle functions use refs for latest state.
|
|
||||||
useEffect(() => {
|
|
||||||
const init = async () => {
|
|
||||||
if (initDoneRef.current) return;
|
|
||||||
initDoneRef.current = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const saved = await AsyncStorage.getItem(DEVICE_STORAGE_KEY);
|
|
||||||
if (saved) {
|
|
||||||
const parsed = JSON.parse(saved) as { device?: AppState['device']; deviceToken?: string };
|
|
||||||
setAppState({
|
|
||||||
device: parsed.device ?? null,
|
|
||||||
deviceToken: parsed.deviceToken ?? null,
|
|
||||||
onboardingForm: {
|
|
||||||
...stateRef.current.onboardingForm,
|
|
||||||
name: parsed.device?.name ?? '',
|
|
||||||
role: parsed.device?.role ?? 'client',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore invalid saved payload
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const session = await api.auth.getSession();
|
|
||||||
if ((session as { session?: unknown })?.session) {
|
|
||||||
setAppState({ session: session as AppState['session'] });
|
|
||||||
|
|
||||||
if (stateRef.current.deviceToken) {
|
|
||||||
connectSocket();
|
|
||||||
startPolling();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAppState({ session: null });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setAppState({ session: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
setReady(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
void init();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
void cleanupConnectionState();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const actions: AppActions = {
|
|
||||||
setPage(page) {
|
|
||||||
setAppState({ page });
|
|
||||||
if (page === 'activity') {
|
|
||||||
markAllNotificationsRead();
|
|
||||||
}
|
|
||||||
if (page === 'client') {
|
|
||||||
void pollClientData();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setAuthField(field, value) {
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
authForm: { ...prev.authForm, [field]: value },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleAuthMode() {
|
|
||||||
patchAppState((prev) => ({ isRegistering: !prev.isRegistering }));
|
|
||||||
},
|
|
||||||
|
|
||||||
async submitAuth() {
|
|
||||||
const current = stateRef.current;
|
|
||||||
const { email, password, name } = current.authForm;
|
|
||||||
const normalizedName = name || email.split('@')[0] || 'User';
|
|
||||||
|
|
||||||
if (!email.trim() || !password.trim()) {
|
|
||||||
pushToast('Email and password are required', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (current.isRegistering) {
|
|
||||||
await api.auth.signUp({ email: email.trim(), password, name: normalizedName });
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.auth.signIn({ email: email.trim(), password });
|
|
||||||
const session = await api.auth.getSession();
|
|
||||||
setAppState({
|
|
||||||
session: session as AppState['session'],
|
|
||||||
authForm: { ...current.authForm, password: '' },
|
|
||||||
});
|
|
||||||
pushToast('Signed in successfully', 'success');
|
|
||||||
|
|
||||||
if (stateRef.current.deviceToken) {
|
|
||||||
connectSocket();
|
|
||||||
startPolling();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Authentication failed';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setOnboardingField(field, value) {
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
onboardingForm: { ...prev.onboardingForm, [field]: value },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
selectRole(role) {
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
onboardingForm: { ...prev.onboardingForm, role },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
async registerDevice() {
|
|
||||||
const { onboardingForm } = stateRef.current;
|
|
||||||
const name = onboardingForm.name.trim() || 'Mobile Dashboard';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload: Record<string, unknown> = {
|
|
||||||
name,
|
|
||||||
role: onboardingForm.role,
|
|
||||||
platform: 'mobile',
|
|
||||||
appVersion: 'mobileapp-1.0',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (onboardingForm.pushToken.trim()) {
|
|
||||||
payload.pushToken = onboardingForm.pushToken.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.devices.register(payload);
|
|
||||||
|
|
||||||
setAppState({
|
|
||||||
device: result.device,
|
|
||||||
deviceToken: result.deviceToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
await AsyncStorage.setItem(
|
|
||||||
DEVICE_STORAGE_KEY,
|
|
||||||
JSON.stringify({ device: result.device, deviceToken: result.deviceToken }),
|
|
||||||
);
|
|
||||||
|
|
||||||
connectSocket();
|
|
||||||
startPolling();
|
|
||||||
pushToast('Device registered', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Device registration failed';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadSavedDevice() {
|
|
||||||
try {
|
|
||||||
const saved = await AsyncStorage.getItem(DEVICE_STORAGE_KEY);
|
|
||||||
if (!saved) {
|
|
||||||
pushToast('No saved device found', 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(saved) as { device?: AppState['device']; deviceToken?: string };
|
|
||||||
setAppState({
|
|
||||||
device: parsed.device ?? null,
|
|
||||||
deviceToken: parsed.deviceToken ?? null,
|
|
||||||
onboardingForm: {
|
|
||||||
...stateRef.current.onboardingForm,
|
|
||||||
name: parsed.device?.name ?? '',
|
|
||||||
role: parsed.device?.role ?? 'client',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parsed.deviceToken && stateRef.current.session) {
|
|
||||||
connectSocket();
|
|
||||||
startPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
pushToast('Loaded saved device', 'success');
|
|
||||||
} catch {
|
|
||||||
pushToast('Saved device is invalid', 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async signOut() {
|
|
||||||
try {
|
|
||||||
await api.auth.signOut();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
await cleanupConnectionState();
|
|
||||||
await AsyncStorage.removeItem(DEVICE_STORAGE_KEY);
|
|
||||||
lastMotionEventIdRef.current = null;
|
|
||||||
|
|
||||||
setState({
|
|
||||||
...createInitialState(),
|
|
||||||
toasts: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
pushToast('Signed out', 'info');
|
|
||||||
},
|
|
||||||
|
|
||||||
async startMotion() {
|
|
||||||
if (!stateRef.current.cameraPermissionGranted) {
|
|
||||||
pushToast('Camera permission is required', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await api.events.startMotion();
|
|
||||||
lastMotionEventIdRef.current = response.event.id;
|
|
||||||
setAppState({ isMotionActive: true, cameraStatus: 'recording' });
|
|
||||||
addActivity('Motion', `Started event ${response.event.id}`);
|
|
||||||
pushToast('Motion event started', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to start motion';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async endMotion() {
|
|
||||||
const eventId = lastMotionEventIdRef.current;
|
|
||||||
if (!eventId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const streamSessionId = activeRecordingStreamSessionIdRef.current;
|
|
||||||
if (streamSessionId) {
|
|
||||||
await api.streams.end(streamSessionId).catch(() => undefined);
|
|
||||||
stopFrameRelay();
|
|
||||||
activeRecordingStreamSessionIdRef.current = null;
|
|
||||||
}
|
|
||||||
await api.events.endMotion(eventId);
|
|
||||||
lastMotionEventIdRef.current = null;
|
|
||||||
setAppState({ isMotionActive: false, cameraStatus: 'idle' });
|
|
||||||
addActivity('Motion', 'Ended event');
|
|
||||||
pushToast('Motion ended', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to end motion';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async goOnline() {
|
|
||||||
connectSocket();
|
|
||||||
pushToast('Attempting realtime reconnect', 'info');
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshCameraInputs() {
|
|
||||||
pushToast('Camera input selection is not available in this mobile build', 'info');
|
|
||||||
},
|
|
||||||
|
|
||||||
async selectCameraInput(_cameraInputId: string) {
|
|
||||||
pushToast('Camera input switching is not available in this mobile build', 'info');
|
|
||||||
},
|
|
||||||
|
|
||||||
async linkCamera(cameraDeviceId: string) {
|
|
||||||
const clientDeviceId = stateRef.current.device?.id;
|
|
||||||
if (!clientDeviceId || !cameraDeviceId.trim()) {
|
|
||||||
pushToast('Camera device ID is required', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.devices.link(cameraDeviceId.trim(), clientDeviceId);
|
|
||||||
pushToast('Camera linked', 'success');
|
|
||||||
await pollClientData();
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to link camera';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async renameLinkedCamera(cameraDeviceId: string, nextName: string) {
|
|
||||||
const name = nextName.trim();
|
|
||||||
if (!name) {
|
|
||||||
pushToast('Camera name cannot be empty', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.devices.update(cameraDeviceId, { name });
|
|
||||||
patchAppState((prev) => ({
|
|
||||||
linkedCameras: prev.linkedCameras.map((entry) =>
|
|
||||||
entry.cameraDeviceId === cameraDeviceId ? { ...entry, cameraName: name } : entry,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
pushToast('Camera renamed', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to rename camera';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteLinkedCamera(linkId: string) {
|
|
||||||
const link = stateRef.current.linkedCameras.find((entry) => entry.id === linkId);
|
|
||||||
if (!link) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.devices.unlink(linkId);
|
|
||||||
const remaining = stateRef.current.linkedCameras.filter((entry) => entry.id !== linkId);
|
|
||||||
const isDeletedActive = stateRef.current.activeCameraDeviceId === link.cameraDeviceId;
|
|
||||||
|
|
||||||
requestedStreamsRef.current.delete(link.cameraDeviceId);
|
|
||||||
|
|
||||||
setAppState({
|
|
||||||
linkedCameras: remaining,
|
|
||||||
activeCameraDeviceId: isDeletedActive ? null : stateRef.current.activeCameraDeviceId,
|
|
||||||
activeStreamSessionId: isDeletedActive ? null : stateRef.current.activeStreamSessionId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDeletedActive) {
|
|
||||||
clearClientStream();
|
|
||||||
}
|
|
||||||
|
|
||||||
pushToast('Camera link removed', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to remove camera link';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async requestStream(cameraDeviceId: string) {
|
|
||||||
try {
|
|
||||||
await api.streams.request(cameraDeviceId);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to request stream';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async selectCamera(cameraDeviceId: string) {
|
|
||||||
const sessions = stateRef.current.cameraSessions || {};
|
|
||||||
setAppState({
|
|
||||||
activeCameraDeviceId: cameraDeviceId,
|
|
||||||
activeStreamSessionId: sessions[cameraDeviceId] || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
await actions.requestStream(cameraDeviceId);
|
|
||||||
|
|
||||||
if (!stateRef.current.activeStreamSessionId) {
|
|
||||||
setClientStreamMode('connecting');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
closeStreamViewer() {
|
|
||||||
setAppState({ activeCameraDeviceId: null, activeStreamSessionId: null });
|
|
||||||
clearClientStream();
|
|
||||||
},
|
|
||||||
|
|
||||||
async openRecording(recordingId: string) {
|
|
||||||
try {
|
|
||||||
const result = await api.ops.getRecordingDownloadUrl(recordingId);
|
|
||||||
if (!result?.downloadUrl) {
|
|
||||||
pushToast('Recording URL unavailable', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Linking.openURL(result.downloadUrl);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to open recording';
|
|
||||||
pushToast(message, 'error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async openMotionNotificationTarget(notificationId: string, cameraDeviceId: string) {
|
|
||||||
markMotionNotificationRead(notificationId);
|
|
||||||
if (!cameraDeviceId) return;
|
|
||||||
|
|
||||||
const recs = await api.ops.listRecordings().catch(() => ({ recordings: [] }));
|
|
||||||
const readyRecording = (recs.recordings || [])
|
|
||||||
.filter((recording) => recording.cameraDeviceId === cameraDeviceId && recording.status === 'ready')
|
|
||||||
.sort(
|
|
||||||
(left, right) =>
|
|
||||||
new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
if (readyRecording?.id) {
|
|
||||||
await actions.openRecording(readyRecording.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await actions.requestStream(cameraDeviceId);
|
|
||||||
},
|
|
||||||
|
|
||||||
markAllNotificationsRead() {
|
|
||||||
markAllNotificationsRead();
|
|
||||||
},
|
|
||||||
|
|
||||||
clearNotifications() {
|
|
||||||
setAppState({ motionNotifications: [] });
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshClientData() {
|
|
||||||
await pollClientData();
|
|
||||||
},
|
|
||||||
|
|
||||||
runDiagnostics() {
|
|
||||||
const connected = stateRef.current.socketConnected ? 'connected' : 'disconnected';
|
|
||||||
pushToast(`Diagnostics complete: realtime ${connected}`, 'success');
|
|
||||||
},
|
|
||||||
|
|
||||||
setCameraPermissionGranted(granted: boolean) {
|
|
||||||
setAppState({ cameraPermissionGranted: granted });
|
|
||||||
},
|
|
||||||
|
|
||||||
setCameraPreviewReady(isReady: boolean) {
|
|
||||||
setAppState({ cameraPreviewReady: isReady });
|
|
||||||
},
|
|
||||||
|
|
||||||
setCameraRef(ref: CameraView | null) {
|
|
||||||
cameraRef.current = ref;
|
|
||||||
},
|
|
||||||
|
|
||||||
removeToast,
|
|
||||||
};
|
|
||||||
|
|
||||||
const contextValue: AppContextValue = {
|
|
||||||
state,
|
|
||||||
ready,
|
|
||||||
unreadCount: unreadNotificationsCount(state),
|
|
||||||
actions,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApp(): AppContextValue {
|
|
||||||
const value = useContext(AppContext);
|
|
||||||
if (!value) {
|
|
||||||
throw new Error('useApp must be used within an AppProvider');
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
|
||||||
|
|
||||||
import { useApp } from '@/src/app-context';
|
|
||||||
|
|
||||||
const palette = {
|
|
||||||
info: '#1f2937',
|
|
||||||
success: '#166534',
|
|
||||||
error: '#991b1b',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ToastOverlay() {
|
|
||||||
const { state, actions } = useApp();
|
|
||||||
|
|
||||||
if (state.toasts.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View pointerEvents="box-none" style={styles.container}>
|
|
||||||
{state.toasts.map((toast) => (
|
|
||||||
<View key={toast.id} style={[styles.toast, { backgroundColor: palette[toast.type] }]}>
|
|
||||||
<Text style={styles.message}>{toast.message}</Text>
|
|
||||||
<Pressable onPress={() => actions.removeToast(toast.id)}>
|
|
||||||
<Text style={styles.dismiss}>x</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 52,
|
|
||||||
left: 12,
|
|
||||||
right: 12,
|
|
||||||
zIndex: 100,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
toast: {
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.15)',
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
color: '#f3f4f6',
|
|
||||||
fontSize: 13,
|
|
||||||
flex: 1,
|
|
||||||
marginRight: 10,
|
|
||||||
},
|
|
||||||
dismiss: {
|
|
||||||
color: '#f3f4f6',
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
paddingHorizontal: 6,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import Constants from 'expo-constants';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
const normalizeBaseUrl = (value: string): string => value.replace(/\/+$/, '');
|
|
||||||
|
|
||||||
const hostFromExpo = (): string | null => {
|
|
||||||
const hostUri =
|
|
||||||
Constants.expoConfig?.hostUri ??
|
|
||||||
(Constants as unknown as { manifest2?: { extra?: { expoClient?: { hostUri?: string } } } }).manifest2?.extra
|
|
||||||
?.expoClient?.hostUri;
|
|
||||||
|
|
||||||
if (!hostUri) return null;
|
|
||||||
const host = hostUri.split(':')[0]?.trim();
|
|
||||||
return host || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const detectDefaultBaseUrl = (): string => {
|
|
||||||
const host = hostFromExpo();
|
|
||||||
if (host && host !== 'localhost') {
|
|
||||||
return `http://${host}:3000`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
return 'http://10.0.2.2:3000';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'http://localhost:3000';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const API_BASE_URL = normalizeBaseUrl(process.env.EXPO_PUBLIC_API_BASE_URL || detectDefaultBaseUrl());
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
export type AppPage = 'auth' | 'onboarding' | 'camera' | 'client' | 'activity' | 'settings';
|
|
||||||
|
|
||||||
export type ToastType = 'info' | 'success' | 'error';
|
|
||||||
|
|
||||||
export type AppToast = {
|
|
||||||
id: string;
|
|
||||||
type: ToastType;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LinkedCamera = {
|
|
||||||
id: string;
|
|
||||||
cameraDeviceId: string;
|
|
||||||
clientDeviceId: string;
|
|
||||||
cameraName?: string | null;
|
|
||||||
cameraStatus?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MotionNotification = {
|
|
||||||
id: string;
|
|
||||||
cameraDeviceId: string;
|
|
||||||
message: string;
|
|
||||||
createdAt: string;
|
|
||||||
isRead: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActivityLogItem = {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
message: string;
|
|
||||||
createdAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RecordingItem = {
|
|
||||||
id: string;
|
|
||||||
status?: string;
|
|
||||||
createdAt: string;
|
|
||||||
cameraDeviceId?: string;
|
|
||||||
durationSeconds?: number | null;
|
|
||||||
streamSessionId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Device = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: 'camera' | 'client';
|
|
||||||
status?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Session = {
|
|
||||||
user?: {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
};
|
|
||||||
session?: {
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AppState = {
|
|
||||||
page: AppPage;
|
|
||||||
session: Session | null;
|
|
||||||
device: Device | null;
|
|
||||||
deviceToken: string | null;
|
|
||||||
socketConnected: boolean;
|
|
||||||
isMotionActive: boolean;
|
|
||||||
cameraPermissionGranted: boolean;
|
|
||||||
cameraPreviewReady: boolean;
|
|
||||||
cameraStatus: 'idle' | 'recording';
|
|
||||||
linkedCameras: LinkedCamera[];
|
|
||||||
recordings: RecordingItem[];
|
|
||||||
motionNotifications: MotionNotification[];
|
|
||||||
activeCameraDeviceId: string | null;
|
|
||||||
activeStreamSessionId: string | null;
|
|
||||||
activityLog: ActivityLogItem[];
|
|
||||||
cameraSessions: Record<string, string>;
|
|
||||||
connectedStreamSessionIds: string[];
|
|
||||||
loading: boolean;
|
|
||||||
isRegistering: boolean;
|
|
||||||
authForm: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
onboardingForm: {
|
|
||||||
name: string;
|
|
||||||
role: 'camera' | 'client';
|
|
||||||
pushToken: string;
|
|
||||||
};
|
|
||||||
toasts: AppToast[];
|
|
||||||
clientStreamMode: 'none' | 'connecting' | 'unavailable' | 'image' | 'video';
|
|
||||||
clientFallbackFrame: string;
|
|
||||||
clientPlaceholderText: string;
|
|
||||||
lastError: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createInitialState = (): AppState => ({
|
|
||||||
page: 'auth',
|
|
||||||
session: null,
|
|
||||||
device: null,
|
|
||||||
deviceToken: null,
|
|
||||||
socketConnected: false,
|
|
||||||
isMotionActive: false,
|
|
||||||
cameraPermissionGranted: false,
|
|
||||||
cameraPreviewReady: false,
|
|
||||||
cameraStatus: 'idle',
|
|
||||||
linkedCameras: [],
|
|
||||||
recordings: [],
|
|
||||||
motionNotifications: [],
|
|
||||||
activeCameraDeviceId: null,
|
|
||||||
activeStreamSessionId: null,
|
|
||||||
activityLog: [],
|
|
||||||
cameraSessions: {},
|
|
||||||
connectedStreamSessionIds: [],
|
|
||||||
loading: false,
|
|
||||||
isRegistering: false,
|
|
||||||
authForm: {
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
onboardingForm: {
|
|
||||||
name: '',
|
|
||||||
role: 'client',
|
|
||||||
pushToken: '',
|
|
||||||
},
|
|
||||||
toasts: [],
|
|
||||||
clientStreamMode: 'none',
|
|
||||||
clientFallbackFrame: '',
|
|
||||||
clientPlaceholderText: 'Select a camera to view',
|
|
||||||
lastError: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const unreadNotificationsCount = (state: AppState): number =>
|
|
||||||
state.motionNotifications.reduce((count, item) => count + (item.isRead ? 0 : 1), 0);
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "expo/tsconfig.base",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true,
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".expo/types/**/*.ts",
|
|
||||||
"expo-env.d.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||