diff --git a/TODO.md b/TODO.md index 4d19df3..eee33b9 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,9 +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. -- [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)". +- [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. + +- [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/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..214a301 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,32 +117,74 @@ 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) { @@ -192,17 +257,60 @@ 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 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) + showWhatsapp: Boolean(this.contactDetails.showWhatsapp), + // 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:', 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 +356,11 @@ class ContactManager { container.innerHTML = `
-
+

📞 Your Feedback Contact

-
-
+
@@ -273,6 +378,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 +398,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 +418,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 +476,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 +556,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..a3f09f4 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('[data-duplicate-message]'); + if (msgEl) msgEl.textContent = message || ''; + document.body.appendChild(modal); + const ok = modal.querySelector('[data-duplicate-ok]'); + const close = () => modal.remove(); + if (ok) 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}