From 8ff8765b5267567e9b710c7afb40952be1409365 Mon Sep 17 00:00:00 2001 From: jerryagenyi Date: Mon, 2 Feb 2026 21:05:55 +0000 Subject: [PATCH 01/10] feature: Play after device change, WhatsApp country code, stream labels S1/S2/S3, sortable streams --- .claude/settings.local.json | 3 +- TODO.md | 14 ++--- docs/TROUBLESHOOTING.md | 10 ++-- public/components/ContactManager.js | 22 ++------ public/components/FFmpegStreamsManager.js | 51 ++++++++++++++++- public/streams.html | 15 +++-- src/routes/contact.js | 15 ++++- src/routes/streams.js | 31 +++++++++++ src/services/StreamingService.js | 68 +++++++++++++++++------ tests/integration/stream-reorder.test.js | 29 ++++++++++ 10 files changed, 199 insertions(+), 59 deletions(-) create mode 100644 tests/integration/stream-reorder.test.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d7ffe15..231d09c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -52,7 +52,8 @@ "Bash(git merge:*)", "Bash(gh pr create:*)", "Bash(gh pr view:*)", - "Bash(gh pr edit:*)" + "Bash(gh pr edit:*)", + "Bash(git rebase:*)" ] } } diff --git a/TODO.md b/TODO.md index 75e22f4..4d19df3 100644 --- a/TODO.md +++ b/TODO.md @@ -30,20 +30,20 @@ Context: [CLAUDE.md](CLAUDE.md), [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING. - [ ] **Error alert UX** — Show structured error from API (cause + diagnosis: title, solutions); optional collapsible Details and FFmpeg output; centred modal or large toast when stream fails; link to troubleshooting guide. _Note: Implemented (structured modal, diagnosis, stderr in Technical details, scenario tests); QA pending._ _Edge cases: Start All partial failure — toast lists failed stream names (up to 5 + "and N more") and first error reason; test API response shape and frontend message._ -- [ ] **Stability** — Confirm 3+ streams stay stable (see TROUBLESHOOTING.md). +- [x] **Stability** — Confirm 3+ streams stay stable (see TROUBLESHOOTING.md). _CONFIRMED working._ -- [ ] **Device change mid-stream / Play after edit** — After changing a stream's device mid-stream, Play on listener page can show "Authentication Failed"; workaround: Start stream on dashboard, refresh listener page. _See TROUBLESHOOTING.md. Should not require delete/recreate._ +- [x] **Device change mid-stream / Play after edit** — Play proxy now checks Icecast response status; non-200 returns 502 with clear message so listener page no longer shows "Authentication Failed". Listener page shows "Stream not running. Start it on the dashboard, then try Play again." No refresh or delete/recreate needed. _See TROUBLESHOOTING.md §1c._ -- [ ] **Stream labels (optional)** — e.g. prefix streams S1, S2, S3; or better naming idea. +- [x] **Stream labels (optional)** — Streams show S1, S2, S3 from server order; label in `getStats()` and on listener page / dashboard. -- [ ] **Sortable streams (optional)** — Drag-and-drop or up/down to reorder stream list on admin dashboard; persist order (e.g. in config). Works with Stream labels; affects frontend for listeners without refresh. +- [x] **Sortable streams (optional)** — Up/down buttons on dashboard; `POST /api/streams/reorder`; order persisted in `config/streams.json` (`_order`); listener page uses same order and S1/S2/S3 labels without refresh. -- [ ] **Contact: WhatsApp** — Enforce country code, build `wa.me/` link. - -- [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md). +- [x] **Contact: WhatsApp** — Country code enforced (digits 10–15, no leading zero); validation backend + ContactManager; `wa.me/` built on listener page; placeholder "e.g. +44 7123 456789 (country code required)". - [ ] **Update notification UX** — Show notification bell icon when update available; click to open modal with update info and link to latest release. Current: button shows "Updates" and runs manual check on click; should auto-check on load and show bell when updateAvailable=true. +- [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md). + --- ## Reference diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 969797a..5e94de3 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -302,20 +302,18 @@ If you access the dashboard using a virtual adapter IP (e.g., `http://172.29.16. - Switch IPv4 to DHCP if a leftover static `.8` address exists. 4. Share only the subnet-matching URL (e.g., `http://192.168.100.x:3001/streams`). -#### 1c. "Authentication Failed" after changing device mid-stream +#### 1c. Play fails after changing device mid-stream -**Symptoms:** You edit a stream (change the audio device) while it was playing, then click Play on the listener page and see "Authentication Failed". Play only works again after removing and recreating the stream. +**Symptoms:** You edit a stream (change the audio device) while it was playing, then click Play on the listener page. You may see "Stream not running" or previously "Authentication Failed" (Icecast 401 when the mount has no source). -**Likely cause:** After an edit (device change), the stream is stopped and must be started again. The listener page may still be pointing at the old mount or the stream may be in an error state so the proxy or Icecast returns an error that appears as "Authentication Failed". +**Cause:** After an edit (device change), the stream is stopped and must be started again. The play proxy now returns a clear message instead of piping Icecast’s error, so you no longer see "Authentication Failed". **What to do:** 1. On the **admin dashboard**, open the stream and click **Start** (or use Start All) so the stream is running again with the new device. -2. On the **listener page**, refresh the stream list (reload the page or wait for the next poll), then click Play again. +2. On the **listener page**, click Play again (no refresh or delete/recreate needed). 3. If it still fails, check that the stream shows "LIVE" on the dashboard and that Icecast is running. If the stream is in "ERROR" state, fix the device (e.g. select a working source) and click Start again. 4. Only remove and recreate the stream if the mount is stuck (e.g. Icecast still thinks the old source is connected). Restarting Icecast from the dashboard can clear stuck mounts. -**Note:** This behaviour is under review; the app should not require deleting and recreating the stream when only the device is changed. - #### 2. Windows Firewall Blocking Connections **Solution:** diff --git a/public/components/ContactManager.js b/public/components/ContactManager.js index 5e1ee81..572a576 100644 --- a/public/components/ContactManager.js +++ b/public/components/ContactManager.js @@ -128,24 +128,14 @@ class ContactManager { } } - // Validate WhatsApp if showWhatsapp is enabled + // Validate WhatsApp if showWhatsapp is enabled (country code required for wa.me) if (this.contactDetails.showWhatsapp && this.contactDetails.whatsapp) { - const whatsappRegex = /^\+?[1-9]\d{6,14}$/; - const cleanedWhatsapp = this.contactDetails.whatsapp.replace(/[^\d+]/g, ''); - if (!whatsappRegex.test(cleanedWhatsapp)) { - errors.push('Please enter a valid WhatsApp number (e.g., +1234567890 or 1234567890)'); + const digits = this.contactDetails.whatsapp.replace(/\D/g, ''); + if (digits.length < 10 || digits.length > 15 || digits.startsWith('0')) { + errors.push('WhatsApp: include country code (e.g. +44 7123 456789), no leading zero'); } } - - // Validate WhatsApp if showWhatsapp is enabled - if (this.contactDetails.showWhatsapp && this.contactDetails.whatsapp) { - const phoneRegex = /^\+?[1-9]\d{6,14}$/; - const cleanedWhatsapp = this.contactDetails.whatsapp.replace(/[^\d+]/g, ''); - if (!phoneRegex.test(cleanedWhatsapp)) { - errors.push('Please enter a valid WhatsApp number'); - } - } - + return errors; } @@ -319,7 +309,7 @@ class ContactManager { diff --git a/public/components/FFmpegStreamsManager.js b/public/components/FFmpegStreamsManager.js index 53e5be6..d58114b 100644 --- a/public/components/FFmpegStreamsManager.js +++ b/public/components/FFmpegStreamsManager.js @@ -510,6 +510,42 @@ class FFmpegStreamsManager { } } + /** + * Reorder streams (move up/down). Persists order; listener page uses same order and S1/S2/S3 labels. + */ + async moveStreamUp(index) { + if (index <= 0) return; + await this.reorderStreams(index, index - 1); + } + + async moveStreamDown(index) { + if (index >= this.activeStreams.length - 1) return; + await this.reorderStreams(index, index + 1); + } + + async reorderStreams(fromIndex, toIndex) { + try { + const ids = this.activeStreams.map(s => s.id); + const [moved] = ids.splice(fromIndex, 1); + ids.splice(toIndex, 0, moved); + const response = await fetch('/api/streams/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ streamIds: ids }) + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message || 'Failed to reorder'); + } + await this.loadStreams(); + this.render(); + this.showNotification('Stream order updated', 'success'); + } catch (error) { + console.error('Failed to reorder streams:', error); + this.showNotification(`Failed to reorder: ${error.message}`, 'error'); + } + } + /** * Edit a stream (show edit modal) */ @@ -936,7 +972,7 @@ class FFmpegStreamsManager { `; } - return this.activeStreams.map(stream => { + return this.activeStreams.map((stream, index) => { // Use client-side timer for uptime calculation const clientTimer = this.clientTimers.get(stream.id); let uptime = '0s'; @@ -984,15 +1020,24 @@ class FFmpegStreamsManager { return `
- +
+
+ + ${stream.label || 'S' + (index + 1)} + +
${stream.status === 'running' ? '
' : ''}
-

${stream.name || stream.id}

+

${stream.label ? stream.label + ' — ' : ''}${stream.name || stream.id}

diff --git a/public/streams.html b/public/streams.html index 9ea4493..c061e14 100644 --- a/public/streams.html +++ b/public/streams.html @@ -464,7 +464,7 @@

📞 Contact & Feedback

-

${stream.name || stream.id}

+

${stream.label ? stream.label + ' — ' : ''}${stream.name || stream.id}

@@ -519,7 +519,7 @@

${stream
volume_up - Now playing: ${stream.name || stream.id} + Now playing: ${stream.label ? stream.label + ' ' : ''}${stream.name || stream.id}
@@ -616,14 +616,14 @@

${stream errorMessage = 'Stream format not supported by your browser. Try Chrome, Firefox, or Edge for better AAC support.'; } else if (errorCode === 2) { // MEDIA_ERR_NETWORK if (networkState === 2 || networkState === 3) { - errorMessage = 'Network error loading stream. Check if the Icecast server is running and try again.'; + errorMessage = 'Stream not running. Start it on the dashboard, then try Play again.'; } else { errorMessage = 'Network error loading stream. Check your connection and try again.'; } } else if (errorCode === 3) { // MEDIA_ERR_DECODE errorMessage = 'Stream decode error. The audio format may be corrupted or incompatible.'; } else if (networkState === 2 || networkState === 3) { - errorMessage = 'Stream server unavailable. Please check if the Icecast server is running.'; + errorMessage = 'Stream not running. Start it on the dashboard, then try Play again.'; } this.showNotification(errorMessage, 'error'); @@ -1039,13 +1039,16 @@

Copy Stream URL

} if (contactData.showWhatsapp && contactData.whatsapp) { - const whatsappUrl = `https://wa.me/${contactData.whatsapp.replace(/[^\d]/g, '')}?text=Hi%20from%20LANStreamer!`; - contactItems.push(` + const digits = contactData.whatsapp.replace(/\D/g, ''); + if (digits && !digits.startsWith('0')) { + const whatsappUrl = `https://wa.me/${digits}?text=Hi%20from%20LANStreamer!`; + contactItems.push(` chat WhatsApp Chat `); + } } // Hide the entire contact section if no contact details are available diff --git a/src/routes/contact.js b/src/routes/contact.js index eb575d6..e1379fa 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -128,8 +128,8 @@ router.post('/contact-details', async (req, res) => { throw new ValidationError('Invalid phone format', ErrorCodes.INVALID_FORMAT); } - if (whatsapp && !isValidPhone(whatsapp)) { - throw new ValidationError('Invalid WhatsApp number format', ErrorCodes.INVALID_FORMAT); + if (whatsapp && !isValidWhatsApp(whatsapp)) { + throw new ValidationError('WhatsApp number must include country code (e.g. +44 7123 456789). Use digits only with country code, no leading zero.', ErrorCodes.INVALID_FORMAT); } // Prepare contact details @@ -206,6 +206,17 @@ function isValidPhone(phone) { return phoneRegex.test(cleaned); } +/** + * Validate WhatsApp number for wa.me link: digits only with country code (no leading zero). + * E.g. +44 7123 456789 → 447123456789 (valid). 07123456789 (invalid, missing country code). + */ +function isValidWhatsApp(value) { + const digits = (value || '').replace(/\D/g, ''); + if (digits.length < 10 || digits.length > 15) return false; + if (digits.startsWith('0')) return false; // leading zero usually means missing country code + return /^[1-9]\d{9,14}$/.test(digits); +} + /** * POST /api/upload-event-image * Upload event image diff --git a/src/routes/streams.js b/src/routes/streams.js index 50e048e..cee3f1d 100644 --- a/src/routes/streams.js +++ b/src/routes/streams.js @@ -107,6 +107,8 @@ router.post('/restart', async (req, res) => { /** * @route GET /api/streams/play/:streamId * @description Proxy Icecast stream for same-origin playback (avoids CORS and firewall on Icecast port). + * When Icecast returns 4xx (e.g. 401/404 for no source), respond with 502 and a clear message so the + * listener page does not show "Authentication Failed" (e.g. after device change mid-stream). * @access Public */ router.get('/play/:streamId', (req, res) => { @@ -114,6 +116,16 @@ router.get('/play/:streamId', (req, res) => { const port = IcecastService.getActualPort() || 8000; const url = `http://127.0.0.1:${port}/${encodeURIComponent(streamId)}`; http.get(url, (upstream) => { + const status = upstream.statusCode || 0; + if (status !== 200) { + upstream.resume(); // consume body so the connection can close + logger.warn('Stream proxy: Icecast returned non-200', { streamId, status }); + res.status(502).json({ + error: 'Stream not available', + message: 'Stream is not running or mount not ready. Start the stream on the dashboard, then try Play again.' + }); + return; + } const contentType = upstream.headers['content-type'] || 'audio/mpeg'; res.setHeader('Content-Type', contentType); upstream.pipe(res); @@ -157,6 +169,25 @@ router.post('/cleanup', (req, res) => { } }); +/** + * @route POST /api/streams/reorder + * @description Set display order of streams (S1, S2, S3). Persists to config; affects dashboard and listener page. + * @access Public + */ +router.post('/reorder', async (req, res) => { + try { + const { streamIds } = req.body; + if (!Array.isArray(streamIds)) { + return res.status(400).json({ message: 'streamIds must be an array' }); + } + streamingService.setStreamOrder(streamIds); + res.status(200).json({ message: 'Stream order updated', streamIds }); + } catch (error) { + logger.error('Error reordering streams:', error); + res.status(500).json({ message: 'Error reordering streams', error: error.message }); + } +}); + /** * @route POST /api/streams/update * @description Update a stream's configuration. diff --git a/src/services/StreamingService.js b/src/services/StreamingService.js index 13529d6..e6fa157 100644 --- a/src/services/StreamingService.js +++ b/src/services/StreamingService.js @@ -15,6 +15,8 @@ const __dirname = dirname(__filename) class StreamingService { constructor() { this.activeStreams = {} + /** @type {string[]} Display order of stream IDs (S1=first, S2=second, etc.). Persisted in streams.json as _order. */ + this.streamOrder = [] this.ffmpegService = FFmpegService this.streamsConfigPath = join(process.cwd(), 'config', 'streams.json') this.lastIcecastStatus = 'unknown' @@ -42,19 +44,25 @@ class StreamingService { if (fs.existsSync(this.streamsConfigPath)) { const data = fs.readFileSync(this.streamsConfigPath, 'utf8') const persistentStreams = JSON.parse(data) - - // Convert persistent streams to active streams (without process references) - Object.keys(persistentStreams).forEach(streamId => { + const streamIds = Object.keys(persistentStreams).filter(k => k !== '_order') + + this.streamOrder = Array.isArray(persistentStreams._order) + ? persistentStreams._order.filter(id => streamIds.includes(id)) + : streamIds + const missingInOrder = streamIds.filter(id => !this.streamOrder.includes(id)) + this.streamOrder.push(...missingInOrder) + + streamIds.forEach(streamId => { const stream = persistentStreams[streamId] this.activeStreams[streamId] = { ...stream, - status: 'stopped', // Mark as stopped since server restarted + status: 'stopped', ffmpegProcess: null, - needsRestart: true // Flag to indicate this stream needs to be restarted + needsRestart: true } }) - - logger.info(`Loaded ${Object.keys(persistentStreams).length} persistent streams`) + + logger.info(`Loaded ${streamIds.length} persistent streams, order: ${this.streamOrder.length} entries`) } } catch (error) { logger.error('Failed to load persistent streams:', error) @@ -66,14 +74,12 @@ class StreamingService { */ savePersistentStreams() { try { - // Ensure config directory exists const configDir = dirname(this.streamsConfigPath) if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }) } - // Save only the stream configuration (not process references) - const persistentData = {} + const persistentData = { _order: this.streamOrder } Object.keys(this.activeStreams).forEach(streamId => { const stream = this.activeStreams[streamId] persistentData[streamId] = { @@ -87,7 +93,7 @@ class StreamingService { }) fs.writeFileSync(this.streamsConfigPath, JSON.stringify(persistentData, null, 2)) - logger.info(`Saved ${Object.keys(persistentData).length} streams to persistent storage`) + logger.info(`Saved ${Object.keys(persistentData).length - 1} streams to persistent storage`) } catch (error) { logger.error('Failed to save persistent streams:', error) } @@ -222,6 +228,9 @@ class StreamingService { exitCode: null, exitSignal: null } + if (!this.streamOrder.includes(streamId)) { + this.streamOrder.push(streamId) + } // Save to persistent storage this.savePersistentStreams() @@ -872,8 +881,11 @@ class StreamingService { config: { ...stream.config, name: newName || updates.name } } - // Remove old stream entry + // Remove old stream entry and update order (replace old id with new id) delete this.activeStreams[streamId] + const orderIdx = this.streamOrder.indexOf(streamId) + if (orderIdx !== -1) this.streamOrder[orderIdx] = newStreamId + else this.streamOrder.push(newStreamId) logger.info(`Stream ID updated from ${streamId} to ${newStreamId}`) } @@ -949,8 +961,9 @@ class StreamingService { await this.stopStream(streamId) } - // Remove from active streams + // Remove from active streams and from display order delete this.activeStreams[streamId] + this.streamOrder = this.streamOrder.filter(id => id !== streamId) // Save to persistent storage (without the deleted stream) this.savePersistentStreams() @@ -1347,17 +1360,25 @@ class StreamingService { return s }) - // Count based on verified status - const runningStreams = verifiedStreams.filter(s => s.status === 'running') - const errorStreams = verifiedStreams.filter(s => s.status === 'error') + // Order by streamOrder (S1=first, S2=second, etc.); streams not in order go at end + const orderMap = new Map(this.streamOrder.map((id, i) => [id, i])) + const ordered = [...verifiedStreams].sort((a, b) => { + const ai = orderMap.has(a.id) ? orderMap.get(a.id) : this.streamOrder.length + const bi = orderMap.has(b.id) ? orderMap.get(b.id) : this.streamOrder.length + return ai - bi + }) + + const runningStreams = ordered.filter(s => s.status === 'running') + const errorStreams = ordered.filter(s => s.status === 'error') return { - total: verifiedStreams.length, + total: ordered.length, running: runningStreams.length, errors: errorStreams.length, - streams: verifiedStreams.map(s => ({ + streams: ordered.map((s, index) => ({ id: s.id, name: s.name, + label: `S${index + 1}`, status: s.status, deviceId: s.deviceId, inputFile: s.inputFile, @@ -1371,6 +1392,17 @@ class StreamingService { })) } } + + /** + * Set display order of streams (for sortable list). Persists to config. + * @param {string[]} streamIds - Ordered list of stream IDs + */ + setStreamOrder(streamIds) { + const valid = streamIds.filter(id => this.activeStreams[id]) + this.streamOrder = valid.length ? valid : Object.keys(this.activeStreams) + this.savePersistentStreams() + logger.info('Stream order updated', { order: this.streamOrder }) + } } export default new StreamingService() \ No newline at end of file diff --git a/tests/integration/stream-reorder.test.js b/tests/integration/stream-reorder.test.js new file mode 100644 index 0000000..6d86330 --- /dev/null +++ b/tests/integration/stream-reorder.test.js @@ -0,0 +1,29 @@ +/** + * Integration test: POST /api/streams/reorder + * Guards: Reorder route exists, accepts streamIds array, returns 200 + */ +import request from 'supertest'; +import app from '../../src/server.js'; + +describe('POST /api/streams/reorder', () => { + it('returns 200 when streamIds is an array', async () => { + const res = await request(app) + .post('/api/streams/reorder') + .set('Content-Type', 'application/json') + .send({ streamIds: [] }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('streamIds'); + expect(Array.isArray(res.body.streamIds)).toBe(true); + }); + + it('returns 400 when streamIds is not an array', async () => { + const res = await request(app) + .post('/api/streams/reorder') + .set('Content-Type', 'application/json') + .send({ streamIds: 'not-an-array' }); + + expect(res.status).toBe(400); + }); +}); From 9f7936a4b45fd63afa3c172c651cba918dd5f494 Mon Sep 17 00:00:00 2001 From: jerryagenyi Date: Mon, 2 Feb 2026 21:17:17 +0000 Subject: [PATCH 02/10] fix: Play proxy timeout handling, stream order deduplication - Add 10s timeout to play proxy to prevent hanging on Icecast issues - Wrap upstream.resume() in try/catch for error resilience - Add res.headersSent guard to prevent double responses - Deduplicate streamIds in setStreamOrder() to prevent duplicates Follow-up to deep review feedback. --- src/routes/streams.js | 34 ++++++++++++++++++++++++-------- src/services/StreamingService.js | 2 +- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/routes/streams.js b/src/routes/streams.js index cee3f1d..590f36d 100644 --- a/src/routes/streams.js +++ b/src/routes/streams.js @@ -111,27 +111,45 @@ router.post('/restart', async (req, res) => { * listener page does not show "Authentication Failed" (e.g. after device change mid-stream). * @access Public */ +const PLAY_PROXY_TIMEOUT_MS = 10000; + router.get('/play/:streamId', (req, res) => { const { streamId } = req.params; const port = IcecastService.getActualPort() || 8000; const url = `http://127.0.0.1:${port}/${encodeURIComponent(streamId)}`; - http.get(url, (upstream) => { + const clientRequest = http.get(url, { timeout: PLAY_PROXY_TIMEOUT_MS }, (upstream) => { const status = upstream.statusCode || 0; if (status !== 200) { - upstream.resume(); // consume body so the connection can close + try { + upstream.resume(); + } catch (e) { + logger.warn('Stream proxy: failed to consume upstream body', { streamId, error: e.message }); + } logger.warn('Stream proxy: Icecast returned non-200', { streamId, status }); - res.status(502).json({ - error: 'Stream not available', - message: 'Stream is not running or mount not ready. Start the stream on the dashboard, then try Play again.' - }); + if (!res.headersSent) { + res.status(502).json({ + error: 'Stream not available', + message: 'Stream is not running or mount not ready. Start the stream on the dashboard, then try Play again.' + }); + } return; } const contentType = upstream.headers['content-type'] || 'audio/mpeg'; res.setHeader('Content-Type', contentType); upstream.pipe(res); - }).on('error', (err) => { + }); + clientRequest.on('timeout', () => { + clientRequest.destroy(); + if (!res.headersSent) { + logger.warn('Stream proxy: timeout', { streamId }); + res.status(502).json({ error: 'Stream unavailable', message: 'Stream connection timed out.' }); + } + }); + clientRequest.on('error', (err) => { logger.warn('Stream proxy error:', { streamId, message: err.message }); - res.status(502).json({ error: 'Stream unavailable', message: err.message }); + if (!res.headersSent) { + res.status(502).json({ error: 'Stream unavailable', message: err.message }); + } }); }); diff --git a/src/services/StreamingService.js b/src/services/StreamingService.js index e6fa157..9b4bdda 100644 --- a/src/services/StreamingService.js +++ b/src/services/StreamingService.js @@ -1399,7 +1399,7 @@ class StreamingService { */ setStreamOrder(streamIds) { const valid = streamIds.filter(id => this.activeStreams[id]) - this.streamOrder = valid.length ? valid : Object.keys(this.activeStreams) + this.streamOrder = valid.length ? [...new Set(valid)] : Object.keys(this.activeStreams) this.savePersistentStreams() logger.info('Stream order updated', { order: this.streamOrder }) } From aa344330e3065db32b473cf56d5e546ae7c7c073 Mon Sep 17 00:00:00 2001 From: jerryagenyi Date: Mon, 2 Feb 2026 21:33:49 +0000 Subject: [PATCH 03/10] docs: TODO updates (notification tasks, WhatsApp/Event/Contact notes) --- TODO.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 4d19df3..f754672 100644 --- a/TODO.md +++ b/TODO.md @@ -38,10 +38,12 @@ Context: [CLAUDE.md](CLAUDE.md), [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING. - [x] **Sortable streams (optional)** — Up/down buttons on dashboard; `POST /api/streams/reorder`; order persisted in `config/streams.json` (`_order`); listener page uses same order and S1/S2/S3 labels without refresh. -- [x] **Contact: WhatsApp** — Country code enforced (digits 10–15, no leading zero); validation backend + ContactManager; `wa.me/` built on listener page; placeholder "e.g. +44 7123 456789 (country code required)". +- [ ] **Contact: WhatsApp** — Country code enforced (digits 10–15, no leading zero); validation backend + ContactManager; `wa.me/` built on listener page; placeholder "e.g. +44 7123 456789 (country code required)". Not working - [ ] **Update notification UX** — Show notification bell icon when update available; click to open modal with update info and link to latest release. Current: button shows "Updates" and runs manual check on click; should auto-check on load and show bell when updateAvailable=true. +- [ ] **Event Details & Your Contcact components** — Not working as should.. + - [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md). --- From 23bd205bcb317192a572ed4c67df29d5e537ec8d Mon Sep 17 00:00:00 2001 From: jerryagenyi Date: Mon, 2 Feb 2026 22:43:41 +0000 Subject: [PATCH 04/10] feat: notification types, update UX, contact/event fixes, single scrollbar, UX v2 doc - Notification types: simple modal for duplicate name/source; full error UX for real failures - Update UX: bell icon, auto-check on load, modal on click; single header-update-bell-btn - Contact/Event: preserve each other when saving; remove collapsible; contact validate on entry - Layout: remove size-full/flex-1 so one plain page scroll (no inner scrollbar) - Stream labels: S1 - name (hyphen not em dash) - docs: UI-UX-RECOMMENDATIONS-v2.md (glassmorphism); TODO updates --- TODO.md | 8 +- docs/UI-UX-RECOMMENDATIONS-v2.md | 746 ++++++++++++++++++++++ public/components/ContactManager.js | 192 ++++-- public/components/EventManager.js | 75 +-- public/components/FFmpegStreamsManager.js | 87 ++- public/components/HeaderComponent.js | 37 +- public/index.html | 4 +- public/streams.html | 2 +- 8 files changed, 1026 insertions(+), 125 deletions(-) create mode 100644 docs/UI-UX-RECOMMENDATIONS-v2.md diff --git a/TODO.md b/TODO.md index f754672..13f37bc 100644 --- a/TODO.md +++ b/TODO.md @@ -20,7 +20,7 @@ Context: [CLAUDE.md](CLAUDE.md), [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING. - [x] **Workflow/docs** — Branch workflow: feature → merge into dev locally → push dev → PR dev→main (remote only). Local branches: dev + features only; `main` deleted locally (remote-only). AGENTS.md removed; refs point to CLAUDE.md. PR #6 updated with lock-admin-localhost changes. -- [ ] **Notification types** — Different types of notification: for duplicate stream name or duplicate source, use a simple modal ("stream already exists") without troubleshooting link; for real failures, use existing error handling with diagnosis/link. +- [x] **Notification types** — For duplicate stream name or duplicate source, use a simple modal ("stream already exists") without troubleshooting link; for real failures, existing error handling with diagnosis/link. Implemented in FFmpegStreamsManager: isDuplicateError(), showDuplicateModal(); used for start, restart, update, start-all. - [ ] **Source validation & clear errors** — On stream failure, check if source (device/file) is viable and say so in the error. Optional: "Test source" button per source. _Note: Test source button + Show test tools toggle and structured errors implemented; QA pending._ @@ -38,11 +38,11 @@ Context: [CLAUDE.md](CLAUDE.md), [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING. - [x] **Sortable streams (optional)** — Up/down buttons on dashboard; `POST /api/streams/reorder`; order persisted in `config/streams.json` (`_order`); listener page uses same order and S1/S2/S3 labels without refresh. -- [ ] **Contact: WhatsApp** — Country code enforced (digits 10–15, no leading zero); validation backend + ContactManager; `wa.me/` built on listener page; placeholder "e.g. +44 7123 456789 (country code required)". Not working +- [x] **Contact: WhatsApp** — Country code enforced (digits 10–15, no leading zero); validation backend + ContactManager; `wa.me/` built on listener page; placeholder "e.g. +44 7123 456789 (country code required)". Not working -- [ ] **Update notification UX** — Show notification bell icon when update available; click to open modal with update info and link to latest release. Current: button shows "Updates" and runs manual check on click; should auto-check on load and show bell when updateAvailable=true. +- [x] **Update notification UX** — Bell icon in header; auto-check on load (HeaderComponent.checkForUpdates); when updateAvailable=true bell shows badge and "Update Available"; click opens modal with update info and link to release. Single button id header-update-bell-btn; manual check on click when no update yet. -- [ ] **Event Details & Your Contcact components** — Not working as should.. +- [x] **Event Details & Your Contcact components** — Not working as should. details not saving for contact. event details collapsible doesnt open after saving details. - [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md). diff --git a/docs/UI-UX-RECOMMENDATIONS-v2.md b/docs/UI-UX-RECOMMENDATIONS-v2.md new file mode 100644 index 0000000..1ba6f51 --- /dev/null +++ b/docs/UI-UX-RECOMMENDATIONS-v2.md @@ -0,0 +1,746 @@ +# UI/UX Recommendations for LANStreamer Dashboard v2 + +**Enhanced glassmorphism edition** - inspired by premium frosted glass UI with softer transparency, layered depth, and elegant gradients. + +Reference: [Gradient UI/UX Elements](https://www.freepik.com/free-vector/gradient-ui-ux-elements-background_16829080.htm) + +Implement after stability items in [TODO.md](../TODO.md) are done. + +--- + +## Design Philosophy + +This version focuses on **premium glassmorphism**: +- Lower opacity for subtler glass (0.03-0.08) +- Rich backdrop blur with saturation boost +- Multi-layered depth with inset shadows +- Smooth, professional animation curves +- Performance-aware with accessibility fallbacks + +--- + +## Current State + +The dashboard has a solid dark theme. Action buttons need clearer hierarchy and modern glassmorphism styling. + +--- + +## 1. Glass Card System (Foundation) + +**The core building block for all glass elements.** + +```css +/* Premium glass card base */ +.glass-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glass-card:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.12); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + transform: translateY(-2px); +} + +/* Elevated variant (for key cards) */ +.glass-card-elevated { + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.03) 100% + ); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + 0 8px 32px rgba(102, 126, 234, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +/* Inset variant (for nested content) */ +.glass-card-inset { + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: + inset 0 2px 8px rgba(0, 0, 0, 0.3), + 0 1px 0 rgba(255, 255, 255, 0.05); +} +``` + +--- + +## 2. Gradient Background System + +**Softer, multi-layer background with ambient animation.** + +```css +/* Main page background */ +.body-bg { + background: + /* Static gradient base */ + linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%), + /* Animated ambient glow */ + radial-gradient( + circle at 20% 80%, + rgba(102, 126, 234, 0.15) 0%, + transparent 40% + ), + radial-gradient( + circle at 80% 20%, + rgba(118, 75, 162, 0.1) 0%, + transparent 40% + ); + background-attachment: fixed; + min-height: 100vh; +} + +/* Optional: subtle animation (respects prefers-reduced-motion) */ +@keyframes ambientShift { + 0%, 100% { + background-position: 0% 50%, 100% 50%; + } + 50% { + background-position: 100% 50%, 0% 50%; + } +} + +.body-bg-animated { + animation: ambientShift 20s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .body-bg-animated { + animation: none; + } +} +``` + +--- + +## 3. Premium Glass Buttons + +**Semi-transparent gradients with frosted edge effects.** + +```css +/* Base button with glass effect */ +.btn-glass { + position: relative; + padding: 12px 24px; + border-radius: 12px; + font-weight: 600; + font-size: 14px; + border: none; + cursor: pointer; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +/* Primary - purple gradient */ +.btn-glass-primary { + background: linear-gradient( + 135deg, + rgba(102, 126, 234, 0.8) 0%, + rgba(118, 75, 162, 0.8) 100% + ); + border: 1px solid rgba(255, 255, 255, 0.15); + color: white; +} + +.btn-glass-primary:hover { + background: linear-gradient( + 135deg, + rgba(102, 126, 234, 0.9) 0%, + rgba(118, 75, 162, 0.9) 100% + ); + border-color: rgba(255, 255, 255, 0.25); + box-shadow: + 0 8px 24px rgba(102, 126, 234, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + transform: translateY(-2px); +} + +/* Success - green gradient */ +.btn-glass-success { + background: linear-gradient( + 135deg, + rgba(17, 153, 142, 0.8) 0%, + rgba(56, 239, 125, 0.8) 100% + ); + border: 1px solid rgba(56, 239, 125, 0.3); + color: white; +} + +.btn-glass-success:hover { + box-shadow: + 0 8px 24px rgba(56, 239, 125, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + transform: translateY(-2px); +} + +/* Danger - red gradient */ +.btn-glass-danger { + background: linear-gradient( + 135deg, + rgba(235, 51, 73, 0.8) 0%, + rgba(244, 92, 67, 0.8) 100% + ); + border: 1px solid rgba(244, 92, 67, 0.3); + color: white; +} + +.btn-glass-danger:hover { + box-shadow: + 0 8px 24px rgba(235, 51, 73, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + transform: translateY(-2px); +} + +/* Secondary - glass outline */ +.btn-glass-secondary { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +.btn-glass-secondary:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +/* Icon + text layout */ +.btn-glass { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.btn-glass svg { + width: 18px; + height: 18px; +} +``` + +--- + +## 4. Enhanced Live Badge + +**Dual-layer pulsing effect with ambient glow.** + +```css +/* Live status badge */ +.live-badge { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + background: rgba(56, 239, 125, 0.1); + border: 1px solid rgba(56, 239, 125, 0.3); + border-radius: 20px; + backdrop-filter: blur(12px); + color: #38ef7d; + font-size: 12px; + font-weight: 600; + box-shadow: + 0 0 20px rgba(56, 239, 125, 0.2), + inset 0 1px 0 rgba(56, 239, 125, 0.1); +} + +/* Pulsing dot indicator */ +.live-badge::before { + content: ''; + position: absolute; + left: 8px; + width: 8px; + height: 8px; + background: #38ef7d; + border-radius: 50%; + box-shadow: 0 0 8px #38ef7d; + animation: livePulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.live-badge span { + padding-left: 12px; +} + +/* Badge ambient glow animation */ +.live-badge { + animation: badgeGlow 3s ease-in-out infinite; +} + +@keyframes livePulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.8); + } +} + +@keyframes badgeGlow { + 0%, 100% { + box-shadow: + 0 0 20px rgba(56, 239, 125, 0.2), + inset 0 1px 0 rgba(56, 239, 125, 0.1); + } + 50% { + box-shadow: + 0 0 30px rgba(56, 239, 125, 0.4), + inset 0 1px 0 rgba(56, 239, 125, 0.15); + } +} +``` + +--- + +## 5. Stream Cards + +**Enhanced depth with optional subtle 3D on hover.** + +```css +/* Stream card base */ +.stream-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 20px; + backdrop-filter: blur(20px) saturate(180%); + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.stream-card:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.12); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +/* Optional: subtle 3D perspective on hover */ +.stream-card-3d { + perspective: 1000px; +} + +.stream-card-3d .stream-card { + transform-style: preserve-3d; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.stream-card-3d .stream-card:hover { + transform: rotateX(2deg) rotateY(-2deg) translateZ(10px); +} + +/* IMPORTANT: Respect prefers-reduced-motion */ +@media (prefers-reduced-motion: reduce) { + .stream-card-3d .stream-card:hover { + transform: none; + } +} +``` + +--- + +## 6. Glass Form Inputs + +**Frosted glass input fields with focus states.** + +```css +/* Glass input base */ +.input-glass { + width: 100%; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + color: white; + font-size: 14px; + backdrop-filter: blur(10px); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.input-glass::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.input-glass:focus { + outline: none; + border-color: rgba(102, 126, 234, 0.5); + background: rgba(0, 0, 0, 0.2); + box-shadow: + inset 0 2px 4px rgba(0, 0, 0, 0.2), + 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.input-glass:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Textarea variant */ +.textarea-glass { + min-height: 100px; + resize: vertical; +} +``` + +--- + +## 7. Glass Navigation Bar + +**Frosted header with blur effect.** + +```css +/* Glass header */ +.header-glass { + position: sticky; + top: 0; + z-index: 100; + background: rgba(15, 12, 41, 0.7); + backdrop-filter: blur(20px) saturate(180%); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +/* Logo with subtle glow */ +.logo-glow { + filter: drop-shadow(0 0 8px rgba(102, 126, 234, 0.5)); +} +``` + +--- + +## 8. Enhanced Animation Curves + +**Professional transitions using Material Design curves.** + +```css +:root { + /* Material Design animation curves */ + --ease-emphasized: cubic-bezier(0.4, 0, 0.2, 1); + --ease-emphasized-decelerate: cubic-bezier(0, 0, 0.2, 1); + --ease-emphasized-accelerate: cubic-bezier(0.4, 0, 1, 1); + --ease-standard: cubic-bezier(0.4, 0, 0.6, 1); + --ease-standard-decelerate: cubic-bezier(0, 0, 0.6, 1); + --ease-standard-accelerate: cubic-bezier(0.4, 0, 1, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); +} + +/* Usage examples */ +.button-hover { + transition: all 0.3s var(--ease-emphasized); +} + +.card-lift { + transition: transform 0.4s var(--ease-emphasized-decelerate); +} + +.fade-in { + animation: fadeIn 0.3s var(--ease-emphasized); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +``` + +--- + +## 9. Colour Palette (Enhanced) + +```css +:root { + /* Glass system */ + --glass-bg-subtle: rgba(255, 255, 255, 0.03); + --glass-bg-mid: rgba(255, 255, 255, 0.05); + --glass-bg-strong: rgba(255, 255, 255, 0.08); + + --glass-border-subtle: rgba(255, 255, 255, 0.08); + --glass-border-mid: rgba(255, 255, 255, 0.12); + --glass-border-strong: rgba(255, 255, 255, 0.2); + + /* Gradient overlays */ + --gradient-purple: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --gradient-green: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); + --gradient-red: linear-gradient(135deg, #eb3349 0%, #f45c43 100%); + --gradient-orange: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + + /* Glow effects */ + --glow-purple: 0 0 20px rgba(102, 126, 234, 0.4); + --glow-green: 0 0 20px rgba(56, 239, 125, 0.4); + --glow-red: 0 0 20px rgba(235, 51, 73, 0.4); + + /* Shadow system (with colored variants) */ + --shadow-subtle: 0 2px 8px rgba(0, 0, 0, 0.2); + --shadow-mid: 0 4px 16px rgba(0, 0, 0, 0.3); + --shadow-strong: 0 8px 32px rgba(0, 0, 0, 0.4); + + --shadow-glass: + 0 4px 24px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.05); + + /* Animation curves */ + --ease-emphasized: cubic-bezier(0.4, 0, 0.2, 1); + --ease-standard: cubic-bezier(0.4, 0, 0.6, 1); +} +``` + +--- + +## 10. Accessibility (Enhanced) + +**Critical for animated and 3D effects.** + +```css +/* Respect user motion preferences */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .stream-card-3d .stream-card:hover { + transform: none !important; + } + + .body-bg-animated { + animation: none !important; + } +} + +/* Focus states for keyboard navigation */ +.btn-glass:focus-visible, +.input-glass:focus-visible { + outline: 2px solid rgba(102, 126, 234, 0.6); + outline-offset: 2px; +} + +/* Touch target sizing */ +.btn-glass, +.glass-card { + min-height: 44px; + min-width: 44px; +} + +/* ARIA for icon-only buttons */ +button[aria-label=""] { + position: relative; +} +``` + +--- + +## 11. Icon Recommendations + +**Maintain Material Symbols Rounded for consistency, add hover states.** + +```css +/* Icon styling */ +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + transition: all 0.2s var(--ease-emphasized); +} + +.icon-btn:hover { + background: rgba(255, 255, 255, 0.08); + transform: scale(1.05); +} + +/* Icon spin on action */ +.icon-spin { + animation: spin 1s var(--ease-emphasized); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +``` + +--- + +## 12. Responsive Button Group (Markup) + +```html +
+ + + + + + + + + + + +
+ + +
+ 4/4 Live +
+``` + +--- + +## 13. Micro-interactions + +**Subtle feedback for user actions.** + +```css +/* Success toast */ +.toast-success { + background: rgba(56, 239, 125, 0.1); + border: 1px solid rgba(56, 239, 125, 0.3); + backdrop-filter: blur(20px); + animation: slideIn 0.3s var(--ease-emphasized); +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Loading shimmer */ +.shimmer { + position: relative; + overflow: hidden; +} + +.shimmer::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.1), + transparent + ); + animation: shimmer 2s infinite; +} + +@keyframes shimmer { + to { + left: 100%; + } +} +``` + +--- + +## Implementation Priority + +### Phase 1: Foundation (Start Here) +1. **Glass card system** - Reusable base for all components +2. **Button styling** - Core interactions with glass effect +3. **Gradient background** - Set the stage + +### Phase 2: Enhancement +4. **Live badge** - Dual-layer pulse effect +5. **Form inputs** - Glass fields with focus states +6. **Animation curves** - Switch to emphasized easing + +### Phase 3: Polish (Optional) +7. **3D hover effects** - Add with reduced-motion fallback +8. **Ambient animations** - Slow background shifts +9. **Advanced micro-interactions** - Shimmer, slide-in + +--- + +## Performance Notes + +- **Backdrop-filter** can be expensive - limit to visible elements +- Use `will-change` sparingly and only for animating properties +- Test on lower-end devices +- Provide reduced-motion alternatives + +--- + +## Browser Compatibility + +| Feature | Chrome/Edge | Firefox | Safari | +|---------|-------------|---------|--------| +| backdrop-filter | ✅ 76+ | ✅ 103+ | ✅ 9+ | +| cubic-bezier | ✅ | ✅ | ✅ | +| 3D transforms | ✅ | ✅ | ✅ | +| prefers-reduced-motion | ✅ | ✅ | ✅ | + +--- + +## Version History + +- **v2** - Enhanced glassmorphism with premium frosted glass, layered depth, and professional animation curves +- [v1](./UI-UX-RECOMMENDATIONS.md) - Original recommendations with basic glass effects diff --git a/public/components/ContactManager.js b/public/components/ContactManager.js index 572a576..d8696b2 100644 --- a/public/components/ContactManager.js +++ b/public/components/ContactManager.js @@ -6,10 +6,14 @@ class ContactManager { whatsapp: '', showEmail: false, showPhone: false, - showWhatsapp: false + showWhatsapp: false, + // Event details (preserve when saving contact info) + eventTitle: '', + eventSubtitle: '', + eventImage: '', + aboutDescription: '' }; this.isLoading = false; - this.isCollapsed = true; // Collapsed by default // Rate limiting for saves this.saveTimeout = null; @@ -87,16 +91,21 @@ class ContactManager { console.log('📞 Loading contact details...'); const response = await fetch('/api/contact-details'); const data = await response.json(); - + this.contactDetails = { email: data.email || '', phone: data.phone || '', whatsapp: data.whatsapp || '', showEmail: Boolean(data.showEmail), showPhone: Boolean(data.showPhone), - showWhatsapp: Boolean(data.showWhatsapp) + showWhatsapp: Boolean(data.showWhatsapp), + // Store event details to preserve when saving contact info + eventTitle: data.eventTitle || '', + eventSubtitle: data.eventSubtitle || '', + eventImage: data.eventImage || '', + aboutDescription: data.aboutDescription || '' }; - + console.log('📞 Contact details loaded:', { hasEmail: !!this.contactDetails.email, hasPhone: !!this.contactDetails.phone, @@ -108,33 +117,75 @@ class ContactManager { } } - validateForm() { - const errors = []; - - // Validate email if showEmail is enabled - if (this.contactDetails.showEmail && this.contactDetails.email) { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(this.contactDetails.email)) { - errors.push('Please enter a valid email address'); - } + /** + * Validate single field at time of entry. Returns error message or null if valid/empty. + */ + validateEmailField(value) { + const v = (value || '').trim(); + if (!v) return null; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(v) ? null : 'Please enter a valid email address'; + } + + validatePhoneField(value) { + const v = (value || '').trim(); + if (!v) return null; + const cleaned = v.replace(/[^\d+]/g, ''); + if (!/^\+?[1-9]\d{6,14}$/.test(cleaned)) { + return 'Valid phone (e.g. +1234567890), 7–15 digits'; } - - // Validate phone if showPhone is enabled - if (this.contactDetails.showPhone && this.contactDetails.phone) { - const phoneRegex = /^\+?[1-9]\d{6,14}$/; - const cleanedPhone = this.contactDetails.phone.replace(/[^\d+]/g, ''); - if (!phoneRegex.test(cleanedPhone)) { - errors.push('Please enter a valid phone number (e.g., +1234567890 or 1234567890)'); - } + return null; + } + + validateWhatsAppField(value) { + const v = (value || '').trim(); + if (!v) return null; + const digits = v.replace(/\D/g, ''); + if (digits.length < 10 || digits.length > 15 || digits.startsWith('0')) { + return 'Include country code (e.g. +44 7123 456789), no leading zero'; } + return null; + } - // Validate WhatsApp if showWhatsapp is enabled (country code required for wa.me) - if (this.contactDetails.showWhatsapp && this.contactDetails.whatsapp) { - const digits = this.contactDetails.whatsapp.replace(/\D/g, ''); - if (digits.length < 10 || digits.length > 15 || digits.startsWith('0')) { - errors.push('WhatsApp: include country code (e.g. +44 7123 456789), no leading zero'); + /** + * Update inline validation state for one field (border + error message). + */ + setFieldValidationState(inputId, errorId, message) { + const input = document.getElementById(inputId); + const errorEl = document.getElementById(errorId); + if (!input) return; + if (message) { + input.classList.add('border-red-500'); + input.classList.remove('border-[var(--border-color)]'); + if (errorEl) { + errorEl.textContent = message; + errorEl.classList.remove('hidden'); + } + } else { + input.classList.remove('border-red-500'); + input.classList.add('border-[var(--border-color)]'); + if (errorEl) { + errorEl.textContent = ''; + errorEl.classList.add('hidden'); } } + } + + validateForm() { + const errors = []; + + if (this.contactDetails.email) { + const msg = this.validateEmailField(this.contactDetails.email); + if (msg) errors.push(msg); + } + if (this.contactDetails.phone) { + const msg = this.validatePhoneField(this.contactDetails.phone); + if (msg) errors.push(msg); + } + if (this.contactDetails.whatsapp) { + const msg = this.validateWhatsAppField(this.contactDetails.whatsapp); + if (msg) errors.push(msg); + } return errors; } @@ -176,7 +227,21 @@ class ContactManager { } this.lastSaveTime = now; - + + // Sync form values from DOM so validation sees current input + const emailEl = document.getElementById('contact-email'); + const phoneEl = document.getElementById('contact-phone'); + const whatsappEl = document.getElementById('contact-whatsapp'); + const showEmailEl = document.getElementById('show-email'); + const showPhoneEl = document.getElementById('show-phone'); + const showWhatsappEl = document.getElementById('show-whatsapp'); + if (emailEl) this.contactDetails.email = emailEl.value.trim(); + if (phoneEl) this.contactDetails.phone = phoneEl.value.trim(); + if (whatsappEl) this.contactDetails.whatsapp = whatsappEl.value.trim(); + if (showEmailEl) this.contactDetails.showEmail = showEmailEl.checked; + if (showPhoneEl) this.contactDetails.showPhone = showPhoneEl.checked; + if (showWhatsappEl) this.contactDetails.showWhatsapp = showWhatsappEl.checked; + // Basic form validation for contact fields only const validationErrors = this.validateForm(); if (validationErrors.length > 0) { @@ -194,15 +259,27 @@ class ContactManager { // Sanitize all inputs before sending to API const contactData = { + // Contact fields email: this.sanitizeEmail(this.contactDetails.email), phone: this.sanitizePhone(this.contactDetails.phone), whatsapp: this.sanitizePhone(this.contactDetails.whatsapp), showEmail: Boolean(this.contactDetails.showEmail), showPhone: Boolean(this.contactDetails.showPhone), - showWhatsapp: Boolean(this.contactDetails.showWhatsapp) + showWhatsapp: Boolean(this.contactDetails.showWhatsapp), + // Event fields (preserve existing values) + eventTitle: this.contactDetails.eventTitle || '', + eventSubtitle: this.contactDetails.eventSubtitle || '', + eventImage: this.contactDetails.eventImage || '', + aboutDescription: this.contactDetails.aboutDescription || '', + aboutSubtitle: '' // For future use, keeping empty for now }; - console.log('📞 Sanitized contact data:', contactData); + console.log('📞 Sanitized contact data:', { + email: contactData.email ? '***@***' : 'empty', + phone: contactData.phone ? '***-***' : 'empty', + whatsapp: contactData.whatsapp ? '***-***' : 'empty', + hasEventTitle: !!contactData.eventTitle + }); const response = await fetch('/api/contact-details', { method: 'POST', @@ -248,14 +325,11 @@ class ContactManager { container.innerHTML = `
-
+

📞 Your Feedback Contact

-
-
+
@@ -273,6 +347,7 @@ class ContactManager { class="w-full px-3 py-2 bg-[#111111] border border-[var(--border-color)] rounded-lg text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[var(--primary-color)]/50 focus:border-[var(--primary-color)]" placeholder="admin@example.com" value="${this.contactDetails.email}"> +
@@ -292,6 +367,7 @@ class ContactManager { class="w-full px-3 py-2 bg-[#111111] border border-[var(--border-color)] rounded-lg text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[var(--primary-color)]/50 focus:border-[var(--primary-color)]" placeholder="+1 (234) 567-8900" value="${this.contactDetails.phone}"> +
@@ -311,6 +387,7 @@ class ContactManager { class="w-full px-3 py-2 bg-[#111111] border border-[var(--border-color)] rounded-lg text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[var(--primary-color)]/50 focus:border-[var(--primary-color)]" placeholder="e.g. +44 7123 456789 (country code required)" value="${this.contactDetails.whatsapp}"> +
@@ -368,38 +445,46 @@ class ContactManager { return previews.join(''); } - toggleCollapse() { - this.isCollapsed = !this.isCollapsed; - this.render(); - } - setupEventListeners() { - // Toggle collapse button - document.getElementById('toggle-contact-section')?.addEventListener('click', () => { - this.toggleCollapse(); - }); - - // Email field + // Email field: validate on input const emailInput = document.getElementById('contact-email'); if (emailInput) { emailInput.addEventListener('input', (e) => { this.updateContactField('email', e.target.value); + const msg = this.validateEmailField(e.target.value); + this.setFieldValidationState('contact-email', 'contact-email-error', msg); + }); + emailInput.addEventListener('blur', (e) => { + const msg = this.validateEmailField(e.target.value); + this.setFieldValidationState('contact-email', 'contact-email-error', msg); }); } - // Phone field + // Phone field: validate on input const phoneInput = document.getElementById('contact-phone'); if (phoneInput) { phoneInput.addEventListener('input', (e) => { this.updateContactField('phone', e.target.value); + const msg = this.validatePhoneField(e.target.value); + this.setFieldValidationState('contact-phone', 'contact-phone-error', msg); + }); + phoneInput.addEventListener('blur', (e) => { + const msg = this.validatePhoneField(e.target.value); + this.setFieldValidationState('contact-phone', 'contact-phone-error', msg); }); } - // WhatsApp field + // WhatsApp field: validate on input const whatsappInput = document.getElementById('contact-whatsapp'); if (whatsappInput) { whatsappInput.addEventListener('input', (e) => { this.updateContactField('whatsapp', e.target.value); + const msg = this.validateWhatsAppField(e.target.value); + this.setFieldValidationState('contact-whatsapp', 'contact-whatsapp-error', msg); + }); + whatsappInput.addEventListener('blur', (e) => { + const msg = this.validateWhatsAppField(e.target.value); + this.setFieldValidationState('contact-whatsapp', 'contact-whatsapp-error', msg); }); } @@ -440,6 +525,17 @@ class ContactManager { } else { console.error('❌ Contact save button not found!'); } + + // Initial validation so invalid saved values show errors on load + if (emailInput) { + this.setFieldValidationState('contact-email', 'contact-email-error', this.validateEmailField(this.contactDetails.email)); + } + if (phoneInput) { + this.setFieldValidationState('contact-phone', 'contact-phone-error', this.validatePhoneField(this.contactDetails.phone)); + } + if (whatsappInput) { + this.setFieldValidationState('contact-whatsapp', 'contact-whatsapp-error', this.validateWhatsAppField(this.contactDetails.whatsapp)); + } } updateSaveButton(isLoading) { diff --git a/public/components/EventManager.js b/public/components/EventManager.js index 735fcb3..0e83a53 100644 --- a/public/components/EventManager.js +++ b/public/components/EventManager.js @@ -7,10 +7,16 @@ class EventManager { eventSubtitle: '', eventImage: '', // About section configuration - aboutDescription: '' + aboutDescription: '', + // Contact details (preserve when saving event info) + email: '', + phone: '', + whatsapp: '', + showEmail: false, + showPhone: false, + showWhatsapp: false }; this.isLoading = false; - this.isCollapsed = true; // Collapsed by default this.isInitialized = false; // Rate limiting for saves @@ -102,16 +108,23 @@ class EventManager { console.log('🎯 Loading event details...'); const response = await fetch('/api/contact-details'); const data = await response.json(); - + this.eventDetails = { // Event configuration eventTitle: data.eventTitle || '', eventSubtitle: data.eventSubtitle || '', eventImage: data.eventImage || '', // About section configuration - aboutDescription: data.aboutDescription || '' + aboutDescription: data.aboutDescription || '', + // Contact details (preserve when saving event info) + email: data.email || '', + phone: data.phone || '', + whatsapp: data.whatsapp || '', + showEmail: Boolean(data.showEmail), + showPhone: Boolean(data.showPhone), + showWhatsapp: Boolean(data.showWhatsapp) }; - + console.log('🎯 Event details loaded:', { hasEventTitle: !!this.eventDetails.eventTitle, hasEventSubtitle: !!this.eventDetails.eventSubtitle, @@ -169,12 +182,26 @@ class EventManager { try { console.log('🎯 Saving event settings...'); const eventData = { + // Event fields eventTitle: this.eventDetails.eventTitle, eventSubtitle: this.eventDetails.eventSubtitle, eventImage: this.eventDetails.eventImage, - aboutDescription: this.eventDetails.aboutDescription + aboutDescription: this.eventDetails.aboutDescription, + aboutSubtitle: '', // For future use, keeping empty for now + // Contact fields (preserve existing values) + email: this.eventDetails.email, + phone: this.eventDetails.phone, + whatsapp: this.eventDetails.whatsapp, + showEmail: Boolean(this.eventDetails.showEmail), + showPhone: Boolean(this.eventDetails.showPhone), + showWhatsapp: Boolean(this.eventDetails.showWhatsapp) }; + console.log('🎯 Saving event data with contact details preserved:', { + hasEventTitle: !!eventData.eventTitle, + hasEmail: !!eventData.email + }); + const response = await fetch('/api/contact-details', { method: 'POST', headers: { @@ -188,25 +215,6 @@ class EventManager { if (response.ok) { console.log('✅ Event settings saved successfully'); this.showNotification('Event settings saved successfully', 'success'); - - // Collapse the form after successful save to provide visual feedback - setTimeout(() => { - const formContent = document.querySelector(`#${this.containerId} .space-y-3:not(.hidden)`); - if (formContent) { - // Add smooth closing animation - formContent.style.transition = 'all 0.3s ease-out'; - formContent.style.opacity = '0'; - formContent.style.transform = 'translateY(-10px)'; - - setTimeout(() => { - this.isCollapsed = true; - this.render(); - }, 300); // Wait for animation to complete - } else { - this.isCollapsed = true; - this.render(); - } - }, 1000); // Small delay to let user see the success message } else { throw new Error(data.error || 'Failed to save event settings'); } @@ -278,14 +286,11 @@ class EventManager { container.innerHTML = `
-
+

🎯 Edit Event Details

-
-
+

📅 Event Details

@@ -518,19 +523,9 @@ class EventManager { return previews.join(''); } - toggleCollapse() { - this.isCollapsed = !this.isCollapsed; - this.render(); - } - setupEventListeners() { console.log('🔧 Setting up initial event listeners...'); - // Toggle collapse button - document.getElementById('toggle-event-section')?.addEventListener('click', () => { - this.toggleCollapse(); - }); - // Event configuration fields const eventTitleInput = document.getElementById('event-title'); if (eventTitleInput) { diff --git a/public/components/FFmpegStreamsManager.js b/public/components/FFmpegStreamsManager.js index d58114b..39e3097 100644 --- a/public/components/FFmpegStreamsManager.js +++ b/public/components/FFmpegStreamsManager.js @@ -52,6 +52,44 @@ class FFmpegStreamsManager { return msg; } + /** + * True if the error is a duplicate name or duplicate source (simple modal, no troubleshooting link). + */ + isDuplicateError(message) { + if (!message || typeof message !== 'string') return false; + const m = message.toLowerCase(); + return /already exists/i.test(m) || + /streams in use|source limit|already in use|too many sources|mount.*busy/i.test(m); + } + + /** + * Show a simple modal for duplicate name/source errors (no troubleshooting link). + */ + showDuplicateModal(title, message) { + const modal = document.createElement('div'); + modal.className = 'fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm'; + const safeTitle = title.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c); + modal.innerHTML = ` +
+
+ info +

${safeTitle}

+
+

+
+ +
+
+ `; + const msgEl = modal.querySelector('#duplicate-modal-message'); + if (msgEl) msgEl.textContent = message || ''; + document.body.appendChild(modal); + const ok = document.getElementById('duplicate-modal-ok'); + const close = () => modal.remove(); + ok.addEventListener('click', close); + modal.addEventListener('click', (e) => { if (e.target === modal) close(); }); + } + /** * Initialize the FFmpeg Streams Manager */ @@ -280,10 +318,15 @@ class FFmpegStreamsManager { } } catch (error) { console.error('Failed to start stream:', error); - this.showNotification(error.message || 'Failed to start stream', 'error', { - linkUrl: TROUBLESHOOTING_GUIDE_URL, - linkText: 'troubleshooting guide' - }); + const msg = error.message || 'Failed to start stream'; + if (this.isDuplicateError(msg)) { + this.showDuplicateModal('Stream already exists', msg); + } else { + this.showNotification(msg, 'error', { + linkUrl: TROUBLESHOOTING_GUIDE_URL, + linkText: 'troubleshooting guide' + }); + } } } @@ -361,10 +404,15 @@ class FFmpegStreamsManager { } } catch (error) { console.error('Failed to start stream:', error); - this.showNotification(error.message || 'Failed to restart stream', 'error', { - linkUrl: TROUBLESHOOTING_GUIDE_URL, - linkText: 'troubleshooting guide' - }); + const msg = error.message || 'Failed to restart stream'; + if (this.isDuplicateError(msg)) { + this.showDuplicateModal('Stream already exists', msg); + } else { + this.showNotification(msg, 'error', { + linkUrl: TROUBLESHOOTING_GUIDE_URL, + linkText: 'troubleshooting guide' + }); + } } } @@ -455,8 +503,10 @@ class FFmpegStreamsManager { const hasSuccess = data.started > 0; const hasFailure = data.failed > 0 && data.results && data.results.length > 0; let errorMsg = ''; + let firstErrorRaw = ''; if (hasFailure) { const failed = data.results.filter(r => !r.success); + firstErrorRaw = failed[0]?.error ? String(failed[0].error) : ''; const names = failed.map(r => r.name || r.id).slice(0, 5); const namesText = failed.length > 5 ? `${names.join(', ')} and ${failed.length - 5} more` : names.join(', '); const firstError = failed[0].error ? String(failed[0].error).slice(0, 120) : ''; @@ -468,10 +518,18 @@ class FFmpegStreamsManager { if (hasSuccess) { this.showNotification(data.message || `Started ${data.started} streams`, 'success'); if (hasFailure) { - setTimeout(() => this.showNotification(errorMsg, 'error'), 150); + if (this.isDuplicateError(firstErrorRaw)) { + setTimeout(() => this.showDuplicateModal('Some streams already exist', errorMsg), 150); + } else { + setTimeout(() => this.showNotification(errorMsg, 'error'), 150); + } } } else if (hasFailure) { - this.showNotification(errorMsg, 'error'); + if (this.isDuplicateError(firstErrorRaw)) { + this.showDuplicateModal('Some streams already exist', errorMsg); + } else { + this.showNotification(errorMsg, 'error'); + } } }); } catch (error) { @@ -725,7 +783,12 @@ class FFmpegStreamsManager { // Restore button state on error submitBtn.innerHTML = originalText; submitBtn.disabled = false; - this.showNotification(`Failed to update stream: ${error.message}`, 'error'); + const msg = error.message || 'Failed to update stream'; + if (this.isDuplicateError(msg)) { + this.showDuplicateModal('Stream already exists', msg); + } else { + this.showNotification(`Failed to update stream: ${msg}`, 'error'); + } } }); @@ -1037,7 +1100,7 @@ class FFmpegStreamsManager {
-

${stream.label ? stream.label + ' — ' : ''}${stream.name || stream.id}

+

${stream.label ? stream.label + ' - ' : ''}${stream.name || stream.id}

diff --git a/public/components/HeaderComponent.js b/public/components/HeaderComponent.js index f57e5fc..e838229 100644 --- a/public/components/HeaderComponent.js +++ b/public/components/HeaderComponent.js @@ -18,6 +18,7 @@ class HeaderComponent { await this.loadConfig(); // Load config first to get correct LAN IP this.render(); this.setupEventListeners(); + this.updateBellIcon(); // Sync bell state (default until check completes) this.checkForUpdates(); // Auto-check for updates on load this.isInitialized = true; console.log('✅ HeaderComponent initialization complete'); @@ -60,26 +61,27 @@ class HeaderComponent { if (!bellBtn) return; if (this.updateAvailable) { - // Show bell with notification badge + // Show update icon with notification badge bellBtn.innerHTML = ` - notifications + system_update - `; - bellBtn.classList.add('bg-green-900/30', 'border-green-600/50'); - bellBtn.classList.remove('bg-gray-800/50', 'border-gray-600/30'); + bellBtn.classList.add('bg-green-900/30', 'border-green-600/50', 'text-green-400'); + bellBtn.classList.remove('bg-gray-800/50', 'border-gray-600/30', 'text-gray-300'); bellBtn.title = `Update available: ${this.updateInfo?.latest || ''}`; } else { - // Show bell without badge + // Show update icon without badge bellBtn.innerHTML = ` - notifications - + system_update `; + bellBtn.classList.remove('bg-green-900/30', 'border-green-600/50', 'text-green-400'); + bellBtn.classList.add('bg-gray-800/50', 'border-gray-600/30', 'text-gray-300'); + bellBtn.title = 'Check for Updates'; } } @@ -127,7 +129,10 @@ class HeaderComponent { const viewReleaseBtn = document.getElementById('update-view-release'); if (viewReleaseBtn && this.updateInfo.releaseUrl) { viewReleaseBtn.addEventListener('click', () => { - window.open(this.updateInfo.releaseUrl, '_blank'); + const url = this.updateInfo.releaseUrl; + if (url && (url.startsWith('https://github.com') || url.startsWith('https://api.github.com'))) { + window.open(url, '_blank'); + } }); } @@ -171,14 +176,12 @@ class HeaderComponent {
- + @@ -220,10 +223,8 @@ class HeaderComponent { console.log('🔄 Header update check requested'); // Show loading state - const originalContent = bellBtn.innerHTML; bellBtn.innerHTML = ` refresh - `; bellBtn.disabled = true; diff --git a/public/index.html b/public/index.html index 3cc201b..cf3e4ac 100644 --- a/public/index.html +++ b/public/index.html @@ -95,10 +95,10 @@ -
+
-
+
diff --git a/public/streams.html b/public/streams.html index c061e14..b126328 100644 --- a/public/streams.html +++ b/public/streams.html @@ -464,7 +464,7 @@

📞 Contact & Feedback

-

${stream.label ? stream.label + ' — ' : ''}${stream.name || stream.id}

+

${stream.label ? stream.label + ' - ' : ''}${stream.name || stream.id}

From 61e8b9cf548fa0e8b7a6ed43305b76426732d357 Mon Sep 17 00:00:00 2001 From: jerryagenyi Date: Mon, 2 Feb 2026 23:31:30 +0000 Subject: [PATCH 05/10] PR fixes: ContactManager fetch-before-save, scoped duplicate modal, TODO wording --- TODO.md | 2 +- public/components/ContactManager.js | 47 +++++++++++++++++++---- public/components/FFmpegStreamsManager.js | 10 ++--- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/TODO.md b/TODO.md index 13f37bc..eee33b9 100644 --- a/TODO.md +++ b/TODO.md @@ -42,7 +42,7 @@ Context: [CLAUDE.md](CLAUDE.md), [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING. - [x] **Update notification UX** — Bell icon in header; auto-check on load (HeaderComponent.checkForUpdates); when updateAvailable=true bell shows badge and "Update Available"; click opens modal with update info and link to release. Single button id header-update-bell-btn; manual check on click when no update yet. -- [x] **Event Details & Your Contcact components** — Not working as should. details not saving for contact. event details collapsible doesnt open after saving details. +- [x] **Event Details & Your Contact components** — Not working as they should; details not saving for contact, and the event details collapsible doesn't open after saving details. - [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md). diff --git a/public/components/ContactManager.js b/public/components/ContactManager.js index d8696b2..214a301 100644 --- a/public/components/ContactManager.js +++ b/public/components/ContactManager.js @@ -257,21 +257,52 @@ class ContactManager { try { console.log('📞 Saving contact details...'); - // Sanitize all inputs before sending to API + // Fetch latest server state so we don't overwrite newer event fields saved by EventManager + let eventFields = { + eventTitle: '', + eventSubtitle: '', + eventImage: '', + aboutDescription: '', + aboutSubtitle: '' + }; + try { + const latestRes = await fetch('/api/contact-details'); + if (latestRes.ok) { + const latest = await latestRes.json(); + eventFields = { + eventTitle: latest.eventTitle || '', + eventSubtitle: latest.eventSubtitle || '', + eventImage: latest.eventImage || '', + aboutDescription: latest.aboutDescription || '', + aboutSubtitle: latest.aboutSubtitle || '' + }; + } + } catch (e) { + console.warn('📞 Could not refresh event fields before save, using current state:', e); + eventFields = { + eventTitle: this.contactDetails.eventTitle || '', + eventSubtitle: this.contactDetails.eventSubtitle || '', + eventImage: this.contactDetails.eventImage || '', + aboutDescription: this.contactDetails.aboutDescription || '', + aboutSubtitle: '' + }; + } + + // Sanitize contact inputs; merge with canonical event fields from server const contactData = { - // Contact fields + // Contact fields from form email: this.sanitizeEmail(this.contactDetails.email), phone: this.sanitizePhone(this.contactDetails.phone), whatsapp: this.sanitizePhone(this.contactDetails.whatsapp), showEmail: Boolean(this.contactDetails.showEmail), showPhone: Boolean(this.contactDetails.showPhone), showWhatsapp: Boolean(this.contactDetails.showWhatsapp), - // Event fields (preserve existing values) - eventTitle: this.contactDetails.eventTitle || '', - eventSubtitle: this.contactDetails.eventSubtitle || '', - eventImage: this.contactDetails.eventImage || '', - aboutDescription: this.contactDetails.aboutDescription || '', - aboutSubtitle: '' // For future use, keeping empty for now + // Event fields from latest server state (do not overwrite newer event edits) + eventTitle: eventFields.eventTitle, + eventSubtitle: eventFields.eventSubtitle, + eventImage: eventFields.eventImage, + aboutDescription: eventFields.aboutDescription, + aboutSubtitle: eventFields.aboutSubtitle }; console.log('📞 Sanitized contact data:', { diff --git a/public/components/FFmpegStreamsManager.js b/public/components/FFmpegStreamsManager.js index 39e3097..a3f09f4 100644 --- a/public/components/FFmpegStreamsManager.js +++ b/public/components/FFmpegStreamsManager.js @@ -75,18 +75,18 @@ class FFmpegStreamsManager { info

${safeTitle}

-

+

- +
`; - const msgEl = modal.querySelector('#duplicate-modal-message'); + const msgEl = modal.querySelector('[data-duplicate-message]'); if (msgEl) msgEl.textContent = message || ''; document.body.appendChild(modal); - const ok = document.getElementById('duplicate-modal-ok'); + const ok = modal.querySelector('[data-duplicate-ok]'); const close = () => modal.remove(); - ok.addEventListener('click', close); + if (ok) ok.addEventListener('click', close); modal.addEventListener('click', (e) => { if (e.target === modal) close(); }); } From 9fa0c62b72e8874574f52b0a357ae10769690887 Mon Sep 17 00:00:00 2001 From: jerryagenyi Date: Tue, 3 Feb 2026 00:07:49 +0000 Subject: [PATCH 06/10] UI/UX v2: glass cards, header-glass on streams, EventManager/ContactManager glass-card --- public/components/ContactManager.js | 2 +- public/components/EventManager.js | 2 +- public/components/FFmpegStreamsManager.js | 24 +- public/components/HeaderComponent.js | 24 +- public/components/IcecastManager.js | 4 +- public/index.html | 535 ++++++++++++++++++++-- public/streams.html | 265 +++++++++-- 7 files changed, 761 insertions(+), 95 deletions(-) diff --git a/public/components/ContactManager.js b/public/components/ContactManager.js index 214a301..a522f18 100644 --- a/public/components/ContactManager.js +++ b/public/components/ContactManager.js @@ -355,7 +355,7 @@ class ContactManager { } container.innerHTML = ` -
+

📞 Your Feedback Contact

diff --git a/public/components/EventManager.js b/public/components/EventManager.js index 0e83a53..95bf826 100644 --- a/public/components/EventManager.js +++ b/public/components/EventManager.js @@ -285,7 +285,7 @@ class EventManager { } container.innerHTML = ` -
+

🎯 Edit Event Details

diff --git a/public/components/FFmpegStreamsManager.js b/public/components/FFmpegStreamsManager.js index a3f09f4..2057f0d 100644 --- a/public/components/FFmpegStreamsManager.js +++ b/public/components/FFmpegStreamsManager.js @@ -77,7 +77,7 @@ class FFmpegStreamsManager {

- +
`; @@ -651,7 +651,7 @@ class FFmpegStreamsManager { id="editStreamName" value="${stream.name || ''}" maxlength="50" - class="w-full px-3 py-2 bg-[#1a1a1a] border border-[var(--border-color)] rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-[var(--primary-color)] transition-colors pr-12" + class="w-full px-3 py-2 input-glass rounded-lg text-white placeholder-gray-400 transition-colors pr-12" placeholder="Enter stream name" required > @@ -688,7 +688,7 @@ class FFmpegStreamsManager { @@ -952,7 +952,7 @@ class FFmpegStreamsManager { } this.container.innerHTML = ` -
+
@@ -1008,7 +1008,7 @@ class FFmpegStreamsManager {

No active streams yet. Start streaming audio from any input source.

-
+

🚀 Quick Start Guide:

@@ -1081,7 +1081,7 @@ class FFmpegStreamsManager { } return ` -
+
@@ -1266,7 +1266,7 @@ class FFmpegStreamsManager { if (!this.container) return; this.container.innerHTML = ` -
+

FFmpeg Streams

@@ -1279,7 +1279,7 @@ class FFmpegStreamsManager {
error

${message}

- @@ -1319,7 +1319,7 @@ class FFmpegStreamsManager { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; modal.innerHTML = ` -
+
radio

🎤 Start New Audio Stream

@@ -1346,7 +1346,7 @@ class FFmpegStreamsManager {
@@ -1361,7 +1361,7 @@ class FFmpegStreamsManager { mic Audio Input Source - ${this.audioDevices.map(device => ` diff --git a/public/components/HeaderComponent.js b/public/components/HeaderComponent.js index e838229..9fe757c 100644 --- a/public/components/HeaderComponent.js +++ b/public/components/HeaderComponent.js @@ -94,7 +94,7 @@ class HeaderComponent { const modal = document.createElement('div'); modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50'; modal.innerHTML = ` -
+
system_update
@@ -102,7 +102,7 @@ class HeaderComponent {

A new version of LANStreamer is ready

-
+
Current Version ${this.updateInfo.current} @@ -113,10 +113,10 @@ class HeaderComponent {
- -
@@ -159,13 +159,13 @@ class HeaderComponent { const streamsUrl = this.serverHost ? `http://${this.serverHost}:3001/streams` : '/streams'; container.innerHTML = ` -
+
LANStreamer