From 50760ae66400bfe5c6d11db7b72ab94c4a8164df Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Thu, 26 Feb 2026 10:30:00 +0000 Subject: [PATCH] refactor: Refactored mobile UI --- Backend/README.md | 12 +- Backend/public/mobile-sim-activity.html | 268 ++++++++++++++++ Backend/public/mobile-sim-auth.html | 286 +++++++++++++++++ Backend/public/mobile-sim-camera.html | 320 +++++++++++++++++++ Backend/public/mobile-sim-client.html | 335 ++++++++++++++++++++ Backend/public/mobile-sim-onboarding.html | 290 +++++++++++++++++ Backend/public/mobile-sim-settings.html | 310 ++++++++++++++++++ Backend/public/mobile-sim.js | 366 +++++++++++++++------- 8 files changed, 2068 insertions(+), 119 deletions(-) create mode 100644 Backend/public/mobile-sim-activity.html create mode 100644 Backend/public/mobile-sim-auth.html create mode 100644 Backend/public/mobile-sim-camera.html create mode 100644 Backend/public/mobile-sim-client.html create mode 100644 Backend/public/mobile-sim-onboarding.html create mode 100644 Backend/public/mobile-sim-settings.html diff --git a/Backend/README.md b/Backend/README.md index ce857e5..2a78aee 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -169,7 +169,17 @@ OpenAPI docs are generated from Zod/OpenAPI definitions: | `GET /docs` | Swagger UI | ### Web Mobile Simulator -Use `GET /sim/mobile-sim.html` to run a browser simulator that behaves like the mobile app: +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` + +All simulator pages support the same flow: - Register as `camera` or `client` - Connect Socket.IO with bearer device token - Camera: process incoming `start_stream` commands, fetch publish credentials, start/end motion events diff --git a/Backend/public/mobile-sim-activity.html b/Backend/public/mobile-sim-activity.html new file mode 100644 index 0000000..9f82dc3 --- /dev/null +++ b/Backend/public/mobile-sim-activity.html @@ -0,0 +1,268 @@ + + + + + + + SecureCam Web Dashboard + + + + + + + + + + + +
+ + +
+ + + + + +
+ + +
+
+
+ + OFFLINE +
+ +
+ +
+
+ ?
+ +
+
+
+ + +
+ + + + + + + + + +
+
+ +
+ + + + + + + + + + diff --git a/Backend/public/mobile-sim-auth.html b/Backend/public/mobile-sim-auth.html new file mode 100644 index 0000000..71dd878 --- /dev/null +++ b/Backend/public/mobile-sim-auth.html @@ -0,0 +1,286 @@ + + + + + + + SecureCam Web Dashboard + + + + + + + + + + + +
+ + +
+ + + + + +
+ + +
+
+
+ + OFFLINE +
+ +
+ +
+
+ ?
+ +
+
+
+ + +
+ + +
+
+
+ + + +
+

SecureCam Web

+

Sign in to manage visual security from your browser.

+
+ +
+
+ + +
+
+ +
+ + +
+ +
OR
+ +
+
+
+ + + + + + +
+
+ +
+ + + + + + + + + + diff --git a/Backend/public/mobile-sim-camera.html b/Backend/public/mobile-sim-camera.html new file mode 100644 index 0000000..17d9fa0 --- /dev/null +++ b/Backend/public/mobile-sim-camera.html @@ -0,0 +1,320 @@ + + + + + + + SecureCam Web Dashboard + + + + + + + + + + + +
+ + +
+ + + + + +
+ + +
+
+
+ + OFFLINE +
+ +
+ +
+
+ ?
+ +
+
+
+ + +
+ + + + + + + + + +
+
+ +
+ + + + + + + + + + diff --git a/Backend/public/mobile-sim-client.html b/Backend/public/mobile-sim-client.html new file mode 100644 index 0000000..a42c2fc --- /dev/null +++ b/Backend/public/mobile-sim-client.html @@ -0,0 +1,335 @@ + + + + + + + SecureCam Web Dashboard + + + + + + + + + + + +
+ + +
+ + + + + +
+ + +
+
+
+ + OFFLINE +
+ +
+ +
+
+ ?
+ +
+
+
+ + +
+ + + + + + + + + +
+
+ +
+ + + + + + + + + + diff --git a/Backend/public/mobile-sim-onboarding.html b/Backend/public/mobile-sim-onboarding.html new file mode 100644 index 0000000..6216991 --- /dev/null +++ b/Backend/public/mobile-sim-onboarding.html @@ -0,0 +1,290 @@ + + + + + + + SecureCam Web Dashboard + + + + + + + + + + + +
+ + +
+ + + + + +
+ + +
+
+
+ + OFFLINE +
+ +
+ +
+
+ ?
+ +
+
+
+ + +
+ + + + + + + + + +
+
+ +
+ + + + + + + + + + diff --git a/Backend/public/mobile-sim-settings.html b/Backend/public/mobile-sim-settings.html new file mode 100644 index 0000000..97c7e2c --- /dev/null +++ b/Backend/public/mobile-sim-settings.html @@ -0,0 +1,310 @@ + + + + + + + SecureCam Web Dashboard + + + + + + + + + + + +
+ + +
+ + + + + +
+ + +
+
+
+ + OFFLINE +
+ +
+ +
+
+ ?
+ +
+
+
+ + +
+ + + + + + + + + +
+
+ +
+ + + + + + + + + + diff --git a/Backend/public/mobile-sim.js b/Backend/public/mobile-sim.js index 522ecc9..5112f8a 100644 --- a/Backend/public/mobile-sim.js +++ b/Backend/public/mobile-sim.js @@ -47,6 +47,52 @@ const store = new Store({ loading: false, // global loading spinner state if needed }); +const PAGE_PATHS = { + auth: '/sim/mobile-sim-auth.html', + onboarding: '/sim/mobile-sim-onboarding.html', + camera: '/sim/mobile-sim-camera.html', + client: '/sim/mobile-sim-client.html', + activity: '/sim/mobile-sim-activity.html', + settings: '/sim/mobile-sim-settings.html', +}; + +const currentPageKey = document.body?.dataset?.page || ''; +const multiPageMode = Boolean(currentPageKey); + +const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client'); + +const getPathForScreen = (screen, role) => { + if (screen === 'home') { + return PAGE_PATHS[getHomePageKeyForRole(role)]; + } + return PAGE_PATHS[screen] || null; +}; + +const navigateToScreen = (screen, options = {}) => { + const { replace = false, role = store.get().device?.role } = options; + const targetPath = getPathForScreen(screen, role); + + if (multiPageMode && targetPath && window.location.pathname !== targetPath) { + if (replace) { + window.location.replace(targetPath); + } else { + window.location.assign(targetPath); + } + return true; + } + + store.update({ screen }); + return false; +}; + +const getScreenForCurrentPage = () => { + if (currentPageKey === 'activity') return 'activity'; + if (currentPageKey === 'settings') return 'settings'; + if (currentPageKey === 'onboarding') return 'onboarding'; + if (currentPageKey === 'camera' || currentPageKey === 'client') return 'home'; + return 'auth'; +}; + // --- 2. UI Utilities --- const $ = (selector) => { // If it looks like a simple ID (no spaces, dots, hash), use getElementById @@ -209,27 +255,68 @@ const init = async () => { if (session && session.session) { store.update({ session }); if (store.get().deviceToken) { - // If we have a token, skip onboarding - navigateBasedOnRole(); + const role = store.get().device?.role; + if (multiPageMode && (currentPageKey === 'auth' || currentPageKey === 'onboarding')) { + if (navigateToScreen('home', { replace: true, role })) return; + } + if (multiPageMode && (currentPageKey === 'camera' || currentPageKey === 'client')) { + const expectedHome = getHomePageKeyForRole(role); + if (expectedHome !== currentPageKey) { + if (navigateToScreen('home', { replace: true, role })) return; + } + } + + if (multiPageMode) { + store.update({ screen: getScreenForCurrentPage() }); + } else { + navigateBasedOnRole(); + } connectSocket(); startPolling(); } else { - store.update({ screen: 'onboarding' }); + if (multiPageMode) { + if (currentPageKey !== 'onboarding') { + if (navigateToScreen('onboarding', { replace: true })) return; + } else { + store.update({ screen: 'onboarding' }); + } + } else { + store.update({ screen: 'onboarding' }); + } + } + } else { + if (multiPageMode) { + if (currentPageKey !== 'auth') { + if (navigateToScreen('auth', { replace: true })) return; + } else { + store.update({ screen: 'auth' }); + } + } else { + store.update({ screen: 'auth' }); + } + } + } catch { + if (multiPageMode) { + if (currentPageKey !== 'auth') { + if (navigateToScreen('auth', { replace: true })) return; + } else { + store.update({ screen: 'auth' }); } } else { store.update({ screen: 'auth' }); } - } catch { - store.update({ screen: 'auth' }); } }; const navigateBasedOnRole = () => { const { device } = store.get(); - if (!device) return store.update({ screen: 'onboarding' }); + if (!device) { + navigateToScreen('onboarding'); + return; + } // Default home screen based on role - store.update({ screen: 'home' }); + navigateToScreen('home', { role: device.role }); }; const startCameraPreview = async () => { @@ -1258,11 +1345,16 @@ const Actions = { // Proceed if (store.get().deviceToken) { - navigateBasedOnRole(); + const role = store.get().device?.role; + if (multiPageMode && currentPageKey === 'auth') { + if (navigateToScreen('home', { replace: true, role })) return; + } else { + navigateBasedOnRole(); + } connectSocket(); startPolling(); } else { - store.update({ screen: 'onboarding' }); + if (navigateToScreen('onboarding')) return; } } catch (e) { // handled by API wrapper toast @@ -1325,6 +1417,7 @@ const Actions = { teardownPeerConnection(); stopCameraPreview(); localStorage.removeItem('mobileSimDevice'); + if (navigateToScreen('auth', { replace: true })) return; Toast.show('Signed Out', 'info'); }, @@ -1470,7 +1563,7 @@ const Actions = { return; } - store.update({ screen: 'home' }); + if (navigateToScreen('home')) return; await Actions.requestStream(cameraDeviceId); }, }; @@ -1481,31 +1574,45 @@ const render = (state) => { // 1. Screen Visibility $$('section[id^="screen-"]').forEach(el => el.classList.add('hidden')); + const showSectionById = (id) => { + const element = $(id); + if (!element) return false; + element.classList.remove('hidden'); + return true; + }; + if (state.screen === 'home') { - const homeId = state.device?.role === 'camera' ? 'screen-home-camera' : 'screen-home-client'; - $(homeId).classList.remove('hidden'); + const preferredHomeId = state.device?.role === 'camera' ? 'screen-home-camera' : 'screen-home-client'; + if (!showSectionById(preferredHomeId)) { + const fallbackHomeId = preferredHomeId === 'screen-home-camera' ? 'screen-home-client' : 'screen-home-camera'; + showSectionById(fallbackHomeId); + } } else { - $(`screen-${state.screen}`).classList.remove('hidden'); + showSectionById(`screen-${state.screen}`); } // 2. Top Bar Status const statusDot = $('#connectionStatus .status-dot'); const statusText = $('#connectionStatus span:last-child'); - if (state.socketConnected) { - statusDot.className = 'status-dot status-online transition-colors duration-300'; - statusText.textContent = 'ONLINE'; - } else { - statusDot.className = 'status-dot status-offline transition-colors duration-300'; - statusText.textContent = 'OFFLINE'; + if (statusDot && statusText) { + if (state.socketConnected) { + statusDot.className = 'status-dot status-online transition-colors duration-300'; + statusText.textContent = 'ONLINE'; + } else { + statusDot.className = 'status-dot status-offline transition-colors duration-300'; + statusText.textContent = 'OFFLINE'; + } } const authBadge = $('authStatusBadge'); - if (state.session?.user) { - authBadge.textContent = state.session.user.email; - authBadge.classList.add('text-blue-400'); - } else { - authBadge.textContent = 'Signed Out'; - authBadge.classList.remove('text-blue-400'); + if (authBadge) { + if (state.session?.user) { + authBadge.textContent = state.session.user.email; + authBadge.classList.add('text-blue-400'); + } else { + authBadge.textContent = 'Signed Out'; + authBadge.classList.remove('text-blue-400'); + } } // 3. Bottom Nav Visibility & State @@ -1513,36 +1620,41 @@ const render = (state) => { const unreadNotifications = state.motionNotifications.filter((notification) => !notification.isRead).length; updateNotificationDot(unreadNotifications > 0); - if (state.session && state.device) { - nav.classList.remove('hidden'); - $$('.nav-btn').forEach(btn => { - const target = btn.dataset.target; - const isActive = target === state.screen || (target === 'home' && (state.screen === 'home-camera' || state.screen === 'home-client')); - btn.setAttribute('data-active', isActive); - // Optional: Force styles for Web/Desktop mode if needed, though data-active is handled by Tailwind variants - }); - } else { - nav.classList.add('hidden'); + if (nav) { + if (state.session && state.device) { + nav.classList.remove('hidden'); + $$('.nav-btn').forEach(btn => { + const target = btn.dataset.target; + const isActive = target === state.screen || (target === 'home' && (state.screen === 'home-camera' || state.screen === 'home-client')); + btn.setAttribute('data-active', isActive); + }); + } else { + nav.classList.add('hidden'); + } } // 4. Camera Mode specifics - if (state.device?.role === 'camera') { + if (state.device?.role === 'camera' && state.screen === 'home') { const preview = $('cameraPreview'); const offlineOverlay = $('cameraOfflineOverlay'); + const startMotionBtn = $('startMotionBtn'); + const endMotionBtn = $('endMotionBtn'); + + if (!preview || !offlineOverlay || !startMotionBtn || !endMotionBtn) return; if (state.socketConnected) { offlineOverlay.classList.add('hidden'); if (state.isMotionActive) { preview.classList.remove('bg-black/50'); preview.classList.add('bg-red-900/20'); - $('startMotionBtn').classList.add('hidden'); - $('endMotionBtn').classList.remove('hidden'); - $('endMotionBtn').disabled = false; + startMotionBtn.classList.add('hidden'); + endMotionBtn.classList.remove('hidden'); + endMotionBtn.disabled = false; } else { preview.classList.add('bg-black/50'); preview.classList.remove('bg-red-900/20'); - $('startMotionBtn').classList.remove('hidden'); - $('endMotionBtn').classList.add('hidden'); + startMotionBtn.classList.remove('hidden'); + endMotionBtn.classList.add('hidden'); } } else { offlineOverlay.classList.remove('hidden'); @@ -1552,10 +1664,11 @@ const render = (state) => { // 5. Client Mode Lists if (state.device?.role === 'client' && state.screen === 'home') { const list = $('linkedCamerasList'); - if (state.linkedCameras.length === 0) { - list.innerHTML = `

No cameras linked yet

`; - } else { - list.innerHTML = state.linkedCameras.map(link => { + if (list) { + if (state.linkedCameras.length === 0) { + list.innerHTML = `

No cameras linked yet

`; + } else { + list.innerHTML = state.linkedCameras.map(link => { const cameraName = getCameraLabel(link.cameraDeviceId, link.cameraName); const escapedCameraName = escapeHtml(cameraName); const cameraStatus = (link.cameraStatus || '').toLowerCase() === 'online' ? 'Online' : 'Offline'; @@ -1632,65 +1745,67 @@ const render = (state) => { `; - }).join(''); + }).join(''); + + // Show/hide main wrapper + const viewerWrapper = $('clientStreamViewerWrapper'); + if (viewerWrapper) { + if (state.activeCameraDeviceId) { + viewerWrapper.classList.remove('hidden'); + const title = $('clientStreamViewerTitle'); + if (title) title.textContent = `Live Feed: ${getCameraLabel(state.activeCameraDeviceId)}`; + } else { + viewerWrapper.classList.add('hidden'); + } + } - // Show/hide main wrapper - const viewerWrapper = $('clientStreamViewerWrapper'); - if (viewerWrapper) { if (state.activeCameraDeviceId) { - viewerWrapper.classList.remove('hidden'); - const title = $('clientStreamViewerTitle'); - if (title) title.textContent = `Live Feed: ${getCameraLabel(state.activeCameraDeviceId)}`; - } else { - viewerWrapper.classList.add('hidden'); - } - } + // Find session ID for active camera if known + let foundSessionId = state.activeStreamSessionId; + const sessions = state.cameraSessions || {}; + if (!foundSessionId && sessions[state.activeCameraDeviceId]) { + foundSessionId = sessions[state.activeCameraDeviceId]; + } - if (state.activeCameraDeviceId) { - // Find session ID for active camera if known - let foundSessionId = state.activeStreamSessionId; - const sessions = state.cameraSessions || {}; - if (!foundSessionId && sessions[state.activeCameraDeviceId]) { - foundSessionId = sessions[state.activeCameraDeviceId]; - } - - const currentStream = foundSessionId ? remoteStreams.get(foundSessionId) : null; - if (currentStream) { - const videoEl = $('clientStreamVideo'); - if (videoEl && videoEl.srcObject !== currentStream) { - videoEl.srcObject = currentStream; - setClientStreamMode('video'); - $('clientLiveDot')?.classList.remove('hidden'); - // Only play if it's not already playing to prevent interruptions - if (videoEl.paused) { - void videoEl.play().catch(() => { }); + const currentStream = foundSessionId ? remoteStreams.get(foundSessionId) : null; + if (currentStream) { + const videoEl = $('clientStreamVideo'); + if (videoEl && videoEl.srcObject !== currentStream) { + videoEl.srcObject = currentStream; + setClientStreamMode('video'); + $('clientLiveDot')?.classList.remove('hidden'); + // Only play if it's not already playing to prevent interruptions + if (videoEl.paused) { + void videoEl.play().catch(() => { }); + } } + } else { + $('clientLiveDot')?.classList.add('hidden'); } } else { $('clientLiveDot')?.classList.add('hidden'); } - } else { - $('clientLiveDot')?.classList.add('hidden'); - } - const imageEl = $('clientStreamImage'); - if (imageEl && !imageEl.dataset.errorBound) { - imageEl.dataset.errorBound = '1'; - imageEl.addEventListener('error', () => { - const videoEl = $('clientStreamVideo'); - if (videoEl) { - videoEl.classList.add('hidden'); - } - setClientStreamMode('unavailable'); - }); + const imageEl = $('clientStreamImage'); + if (imageEl && !imageEl.dataset.errorBound) { + imageEl.dataset.errorBound = '1'; + imageEl.addEventListener('error', () => { + const videoEl = $('clientStreamVideo'); + if (videoEl) { + videoEl.classList.add('hidden'); + } + setClientStreamMode('unavailable'); + }); + } } } const recList = $('recordingsList'); - if (state.recordings.length === 0) { - recList.innerHTML = `

No recordings found

`; - } else { - recList.innerHTML = state.recordings.slice(0, 5).map(rec => ` + if (recList) { + if (state.recordings.length === 0) { + recList.innerHTML = `

No recordings found

`; + } else { + recList.innerHTML = state.recordings.slice(0, 5).map(rec => `
${new Date(rec.createdAt).toLocaleString()} @@ -1701,13 +1816,15 @@ const render = (state) => {
`).join(''); + } } } if (state.screen === 'activity') { const activityFeed = $('activityFeedList'); - if (state.motionNotifications.length === 0) { - activityFeed.innerHTML = ` + if (activityFeed) { + if (state.motionNotifications.length === 0) { + activityFeed.innerHTML = `
@@ -1715,36 +1832,42 @@ const render = (state) => {

No notifications yet

`; - } else { - activityFeed.innerHTML = state.motionNotifications.map((notification) => ` + } else { + activityFeed.innerHTML = state.motionNotifications.map((notification) => ` `).join(''); + } } } // 6. Settings Screen if (state.session?.user && state.screen === 'settings') { - $('profileName').textContent = state.session.user.name; - $('profileEmail').textContent = state.session.user.email; - $('profileInitials').textContent = state.session.user.name.charAt(0).toUpperCase(); + const profileName = $('profileName'); + const profileEmail = $('profileEmail'); + const profileInitials = $('profileInitials'); + if (profileName) profileName.textContent = state.session.user.name; + if (profileEmail) profileEmail.textContent = state.session.user.email; + if (profileInitials) profileInitials.textContent = state.session.user.name.charAt(0).toUpperCase(); } }; const addActivity = (type, msg) => { const list = $('activityFeedList'); - const item = document.createElement('div'); - item.className = 'p-3 rounded-lg bg-gray-900/40 border border-white/5 flex flex-col gap-1'; - item.innerHTML = ` + if (list) { + const item = document.createElement('div'); + item.className = 'p-3 rounded-lg bg-gray-900/40 border border-white/5 flex flex-col gap-1'; + item.innerHTML = `
${type} ${new Date().toLocaleTimeString()}

${msg}

`; - list.prepend(item); + list.prepend(item); + } // Also update camera logs if applicable if ($('cameraLogs')) { @@ -1756,27 +1879,34 @@ const addActivity = (type, msg) => { const updateNotificationDot = (show) => { const dot = $('notificationDot'); + if (!dot) return; if (show) dot.classList.remove('hidden'); else dot.classList.add('hidden'); }; // --- 6. Event Listeners --- -$('toggleAuthModeBtn').addEventListener('click', Actions.toggleAuthMode); -$('signInBtn').addEventListener('click', Actions.submitAuth); -$('registerBtn').addEventListener('click', Actions.registerDevice); -$('loadSavedBtn').addEventListener('click', () => { /* Handle legacy loading if needed */ }); +const bind = (id, eventName, handler) => { + const element = $(id); + if (!element) return; + element.addEventListener(eventName, handler); +}; + +bind('toggleAuthModeBtn', 'click', Actions.toggleAuthMode); +bind('signInBtn', 'click', Actions.submitAuth); +bind('registerBtn', 'click', Actions.registerDevice); +bind('loadSavedBtn', 'click', () => { /* Handle legacy loading if needed */ }); $$('#screen-onboarding [data-role]').forEach((btn) => { btn.addEventListener('click', () => Actions.selectRole(btn.dataset.role)); }); -$('recordingsList').addEventListener('click', (event) => { +bind('recordingsList', 'click', (event) => { const target = event.target.closest('.download-recording-btn'); if (!target || target.disabled) return; const recordingId = target.dataset.recordingId; if (!recordingId) return; Actions.openRecording(recordingId); }); -$('activityFeedList').addEventListener('click', (event) => { +bind('activityFeedList', 'click', (event) => { const target = event.target.closest('.motion-notification-btn'); if (!target) return; const notificationId = target.dataset.notificationId; @@ -1857,31 +1987,31 @@ $$('.nav-btn').forEach(btn => { if (btn.dataset.target === 'activity') { markAllNotificationsRead(); } - store.update({ screen: btn.dataset.target }); + if (navigateToScreen(btn.dataset.target)) return; }); }); // Camera Controls -$('cameraGoOnlineBtn').addEventListener('click', async () => { +bind('cameraGoOnlineBtn', 'click', async () => { if (store.get().device?.role === 'camera') { await startCameraPreview(); } connectSocket(); }); -$('startMotionBtn').addEventListener('click', Actions.startMotion); -$('endMotionBtn').addEventListener('click', Actions.endMotion); +bind('startMotionBtn', 'click', Actions.startMotion); +bind('endMotionBtn', 'click', Actions.endMotion); // Client Controls -$('linkCameraBtn').addEventListener('click', Actions.linkCamera); -$('refreshClientBtn').addEventListener('click', startPolling); +bind('linkCameraBtn', 'click', Actions.linkCamera); +bind('refreshClientBtn', 'click', startPolling); // Settings -$('signOutBtn').addEventListener('click', Actions.signOut); -$('clearActivityBtn').addEventListener('click', () => { +bind('signOutBtn', 'click', Actions.signOut); +bind('clearActivityBtn', 'click', () => { store.update({ motionNotifications: [] }); }); -$('recordingModalCloseBtn').addEventListener('click', Actions.closeRecordingModal); -$('recordingModal').addEventListener('click', (event) => { +bind('recordingModalCloseBtn', 'click', Actions.closeRecordingModal); +bind('recordingModal', 'click', (event) => { if (event.target === $('recordingModal')) { Actions.closeRecordingModal(); }