refactor: Refactored mobile UI

This commit is contained in:
2026-02-26 10:30:00 +00:00
parent bcdc16576a
commit 50760ae664
8 changed files with 2068 additions and 119 deletions

View File

@@ -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 = `<div class="min-w-full text-center py-8 bg-gray-900/30 rounded-xl border border-dashed border-gray-800"><p class="text-gray-600 text-xs">No cameras linked yet</p></div>`;
} else {
list.innerHTML = state.linkedCameras.map(link => {
if (list) {
if (state.linkedCameras.length === 0) {
list.innerHTML = `<div class="min-w-full text-center py-8 bg-gray-900/30 rounded-xl border border-dashed border-gray-800"><p class="text-gray-600 text-xs">No cameras linked yet</p></div>`;
} 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) => {
</div>
</div>
`;
}).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 = `<div class="text-center py-4 bg-gray-900/30 rounded-xl"><p class="text-gray-600 text-xs text-center">No recordings found</p></div>`;
} else {
recList.innerHTML = state.recordings.slice(0, 5).map(rec => `
if (recList) {
if (state.recordings.length === 0) {
recList.innerHTML = `<div class="text-center py-4 bg-gray-900/30 rounded-xl"><p class="text-gray-600 text-xs text-center">No recordings found</p></div>`;
} else {
recList.innerHTML = state.recordings.slice(0, 5).map(rec => `
<div class="flex items-center justify-between p-3 bg-gray-900/40 rounded-lg border border-white/5 hover:bg-gray-800 transition-colors">
<div class="flex flex-col">
<span class="text-xs font-medium text-gray-300">${new Date(rec.createdAt).toLocaleString()}</span>
@@ -1701,13 +1816,15 @@ const render = (state) => {
</button>
</div>
`).join('');
}
}
}
if (state.screen === 'activity') {
const activityFeed = $('activityFeedList');
if (state.motionNotifications.length === 0) {
activityFeed.innerHTML = `
if (activityFeed) {
if (state.motionNotifications.length === 0) {
activityFeed.innerHTML = `
<div class="text-center py-10 opacity-50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mx-auto mb-2 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" />
@@ -1715,36 +1832,42 @@ const render = (state) => {
<p class="text-sm text-gray-500">No notifications yet</p>
</div>
`;
} else {
activityFeed.innerHTML = state.motionNotifications.map((notification) => `
} else {
activityFeed.innerHTML = state.motionNotifications.map((notification) => `
<button class="w-full text-left p-3 rounded-lg border border-white/5 ${notification.isRead ? 'bg-gray-900/30' : 'bg-blue-900/20'} motion-notification-btn" data-notification-id="${notification.id}" data-camera-device-id="${notification.cameraDeviceId}">
<p class="text-xs font-medium text-gray-200">${notification.message}</p>
<p class="text-[10px] text-gray-500 mt-1">${new Date(notification.createdAt).toLocaleString()}</p>
</button>
`).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 = `
<div class="flex justify-between items-start">
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">${type}</span>
<span class="text-[10px] text-gray-600">${new Date().toLocaleTimeString()}</span>
</div>
<p class="text-xs text-gray-300">${msg}</p>
`;
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();
}